@codeworksh/harness 0.1.0-dev.20261007133219 → 0.2.0-canary.8.1.g763b062a
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/{daytona-BAurx6Gp.mjs → daytona-ByTHZelc.mjs} +24 -14
- package/daytona-ByTHZelc.mjs.map +1 -0
- package/effect.d.mts +900 -794
- package/effect.mjs +1919 -192
- package/effect.mjs.map +1 -1
- package/{index-DIP2u0Pr.d.mts → index-BEZ8eL--.d.mts} +2 -1
- package/{index-DjPzwUHF.d.mts → index-BlvIgEtU.d.mts} +2 -1
- package/package.json +5 -3
- package/resize-Dwv5dCgY.mjs +263 -0
- package/resize-Dwv5dCgY.mjs.map +1 -0
- package/sandboxes/daytona/index.d.mts +1 -1
- package/sandboxes/daytona/index.mjs +1 -1
- package/sandboxes/vercel/index.d.mts +1 -1
- package/sandboxes/vercel/index.mjs +1 -1
- package/tool/image/worker.d.mts +1 -0
- package/tool/image/worker.mjs +19 -0
- package/tool/image/worker.mjs.map +1 -0
- package/{vercel-C0v5nh20.mjs → vercel-3GouQSJI.mjs} +32 -4
- package/vercel-3GouQSJI.mjs.map +1 -0
- package/daytona-BAurx6Gp.mjs.map +0 -1
- package/vercel-C0v5nh20.mjs.map +0 -1
package/effect.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { n as __reExport, t as __exportAll } from "./rolldown-runtime-B4FMCO8f.mjs";
|
|
2
2
|
import { a as io_exports, i as errors_exports, n as shell_exports, o as instance_exports, r as filesystem_exports, s as posix } from "./resource-DFZh0e-s.mjs";
|
|
3
|
-
import "./daytona-
|
|
4
|
-
import "./vercel-
|
|
3
|
+
import "./daytona-ByTHZelc.mjs";
|
|
4
|
+
import "./vercel-3GouQSJI.mjs";
|
|
5
|
+
import { a as decode, n as dimensionNote, o as load$4, r as resize } from "./resize-Dwv5dCgY.mjs";
|
|
5
6
|
import { createRequire, registerHooks } from "node:module";
|
|
6
7
|
import { Cause, Clock, Config, Context, Crypto, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberSet, FileSystem, Layer, LayerMap, Option, Path, Predicate, PubSub, Queue, Random, Ref, Result, Schedule, Schema, SchemaIssue, Scope, Semaphore, Stream, String as String$1 } from "effect";
|
|
7
|
-
import { Message, Model, getEnvApiKey, llm, stream } from "@codeworksh/aikit";
|
|
8
|
+
import { Message, Model, getEnvApiKey, llm, normalizeOptionalNulls, stream } from "@codeworksh/aikit";
|
|
8
9
|
import { MessageID as ID, SessionID as ID$1 } from "@codeworksh/plugin/ids";
|
|
9
10
|
import { Migrator, SqlClient, SqlClient as SqlClient$1, SqlError, SqlSchema, SqlSchema as SqlSchema$1 } from "effect/sql";
|
|
10
11
|
import { uuidv7 } from "uuidv7";
|
|
11
12
|
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
|
|
12
13
|
import * as os from "node:os";
|
|
13
|
-
import { homedir
|
|
14
|
+
import { homedir } from "node:os";
|
|
14
15
|
import { NodeChildProcessSpawner, NodeCrypto, NodeFileSystem, NodePath } from "@effect/platform-node";
|
|
15
16
|
import { Model as Model$1, VariantSchema } from "effect/schema";
|
|
16
17
|
import { Hex } from "effect/encoding";
|
|
@@ -25,6 +26,8 @@ import { JsonGitHubCopilotAuthStorage, getGitHubCopilotApiKey } from "@codeworks
|
|
|
25
26
|
import { JsonOpenAICodexAuthStorage, getOpenAICodexApiKey } from "@codeworksh/aikit/oauth/openai/codex";
|
|
26
27
|
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
27
28
|
import * as Section from "@codeworksh/plugin/plugin/section";
|
|
29
|
+
import * as Diff from "diff";
|
|
30
|
+
import { FetchHttpClient, HttpClient } from "effect/http";
|
|
28
31
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
32
|
import path from "node:path";
|
|
30
33
|
import { Script, constants } from "node:vm";
|
|
@@ -139,7 +142,7 @@ var global_exports = /* @__PURE__ */ __exportAll({
|
|
|
139
142
|
layer: () => layer$27,
|
|
140
143
|
layerWith: () => layerWith,
|
|
141
144
|
make: () => make$18,
|
|
142
|
-
resolve: () => resolve$
|
|
145
|
+
resolve: () => resolve$4
|
|
143
146
|
});
|
|
144
147
|
const appConfigDir = ".codework";
|
|
145
148
|
const app = "codework";
|
|
@@ -153,7 +156,8 @@ function make$18(input = {}) {
|
|
|
153
156
|
home,
|
|
154
157
|
cache: input.cache ?? posix.join(home, "cache"),
|
|
155
158
|
data: input.data ?? posix.join(home, "data"),
|
|
156
|
-
log: input.log ?? posix.join(home, "log")
|
|
159
|
+
log: input.log ?? posix.join(home, "log"),
|
|
160
|
+
bin: input.bin ?? posix.join(home, "bin")
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
/**
|
|
@@ -166,7 +170,7 @@ function make$18(input = {}) {
|
|
|
166
170
|
* would be invisible to the other.
|
|
167
171
|
*/
|
|
168
172
|
const authFile = (home) => home === void 0 ? void 0 : posix.join(home, "aikit", "auth.json");
|
|
169
|
-
const resolve$
|
|
173
|
+
const resolve$4 = Effect.fn("Global.resolve")(function* (input = {}) {
|
|
170
174
|
const home = input.home === void 0 ? yield* homeConfig : expandHome(input.home);
|
|
171
175
|
return make$18({
|
|
172
176
|
...input,
|
|
@@ -174,7 +178,7 @@ const resolve$3 = Effect.fn("Global.resolve")(function* (input = {}) {
|
|
|
174
178
|
});
|
|
175
179
|
});
|
|
176
180
|
const build = (input) => Effect.gen(function* () {
|
|
177
|
-
const paths = yield* resolve$
|
|
181
|
+
const paths = yield* resolve$4(input);
|
|
178
182
|
yield* Effect.all([
|
|
179
183
|
fileSystem.makeDirectory(paths.cache, { recursive: true }),
|
|
180
184
|
fileSystem.makeDirectory(paths.data, { recursive: true }),
|
|
@@ -376,7 +380,7 @@ var db_exports = /* @__PURE__ */ __exportAll({
|
|
|
376
380
|
defaultLayer: () => defaultLayer$1,
|
|
377
381
|
layer: () => layer$26,
|
|
378
382
|
locationConfig: () => locationConfig,
|
|
379
|
-
path: () => path$
|
|
383
|
+
path: () => path$2,
|
|
380
384
|
resolveDatabaseLocation: () => resolveDatabaseLocation
|
|
381
385
|
});
|
|
382
386
|
const setup = Effect.gen(function* () {
|
|
@@ -403,11 +407,11 @@ function resolveDatabaseLocation(configured, data) {
|
|
|
403
407
|
if (configured === ":memory:" || posix.isAbsolute(configured)) return configured;
|
|
404
408
|
return posix.join(data, configured);
|
|
405
409
|
}
|
|
406
|
-
const path$
|
|
407
|
-
const directory = data ?? (yield* resolve$
|
|
410
|
+
const path$2 = Effect.fn("Database.path")(function* (data) {
|
|
411
|
+
const directory = data ?? (yield* resolve$4()).data;
|
|
408
412
|
return resolveDatabaseLocation(yield* locationConfig, directory);
|
|
409
413
|
});
|
|
410
|
-
const defaultLayer$1 = Layer.unwrap(path$
|
|
414
|
+
const defaultLayer$1 = Layer.unwrap(path$2().pipe(Effect.map(layer$26)));
|
|
411
415
|
//#endregion
|
|
412
416
|
//#region src/event/schema.ts
|
|
413
417
|
var schema_exports$7 = /* @__PURE__ */ __exportAll({});
|
|
@@ -2665,6 +2669,7 @@ const toFileStat = (stats) => ({
|
|
|
2665
2669
|
size: stats.size,
|
|
2666
2670
|
mtime: stats.mtime
|
|
2667
2671
|
});
|
|
2672
|
+
const SCAN_CHUNK = 65536;
|
|
2668
2673
|
const bytes = (content) => typeof content === "string" ? Buffer$1.from(content, "utf8") : Buffer$1.from(content);
|
|
2669
2674
|
/**
|
|
2670
2675
|
* Provider factory method that implements the SandboxFileSytem
|
|
@@ -2676,6 +2681,8 @@ const make$17 = (vfs) => {
|
|
|
2676
2681
|
await vfs.promises.writeFile(path, bytes(content));
|
|
2677
2682
|
};
|
|
2678
2683
|
const readdir = async (path) => vfs.promises.readdir(path);
|
|
2684
|
+
const open = (path) => new Promise((resolve, reject) => vfs.open(path, "r", (error, value) => error ? reject(error) : resolve(value)));
|
|
2685
|
+
const close = (fd) => new Promise((resolve) => vfs.close(fd, () => resolve()));
|
|
2679
2686
|
const rm = async (path, options) => {
|
|
2680
2687
|
let stats;
|
|
2681
2688
|
try {
|
|
@@ -2715,7 +2722,36 @@ const make$17 = (vfs) => {
|
|
|
2715
2722
|
await vfs.promises.mkdir(path, options);
|
|
2716
2723
|
},
|
|
2717
2724
|
rm,
|
|
2718
|
-
realpath: (path) => vfs.promises.realpath(path)
|
|
2725
|
+
realpath: (path) => vfs.promises.realpath(path),
|
|
2726
|
+
readBytes: async (path, offset, length) => {
|
|
2727
|
+
const fd = await open(path);
|
|
2728
|
+
try {
|
|
2729
|
+
const buffer = Buffer$1.alloc(length);
|
|
2730
|
+
const read = await new Promise((resolve, reject) => vfs.read(fd, buffer, 0, length, offset, (error, bytesRead) => error ? reject(error) : resolve(bytesRead)));
|
|
2731
|
+
return new Uint8Array(buffer.subarray(0, read));
|
|
2732
|
+
} finally {
|
|
2733
|
+
await close(fd);
|
|
2734
|
+
}
|
|
2735
|
+
},
|
|
2736
|
+
scanLines: async (path, options) => {
|
|
2737
|
+
const scanner = new filesystem_exports.SandboxFileSystem.Scan.LineScanner(options);
|
|
2738
|
+
const { size } = await vfs.promises.stat(path);
|
|
2739
|
+
if (size === 0) return scanner.finish();
|
|
2740
|
+
const fd = await open(path);
|
|
2741
|
+
try {
|
|
2742
|
+
const buffer = Buffer$1.alloc(Math.min(SCAN_CHUNK, size));
|
|
2743
|
+
for (let position = 0; position < size;) {
|
|
2744
|
+
const length = Math.min(buffer.length, size - position);
|
|
2745
|
+
const read = await new Promise((resolve, reject) => vfs.read(fd, buffer, 0, length, position, (error, bytesRead) => error ? reject(error) : resolve(bytesRead)));
|
|
2746
|
+
if (read === 0) break;
|
|
2747
|
+
scanner.push(buffer.subarray(0, read));
|
|
2748
|
+
position += read;
|
|
2749
|
+
}
|
|
2750
|
+
} finally {
|
|
2751
|
+
await close(fd);
|
|
2752
|
+
}
|
|
2753
|
+
return scanner.finish();
|
|
2754
|
+
}
|
|
2719
2755
|
};
|
|
2720
2756
|
};
|
|
2721
2757
|
/** The `SandboxFileSystem.Service` backed by the local {@link Vfs}. */
|
|
@@ -2843,10 +2879,12 @@ const layer$19 = (...drivers) => Layer.effect(Registry, make$16(drivers));
|
|
|
2843
2879
|
//#endregion
|
|
2844
2880
|
//#region src/sandbox/shell/host.ts
|
|
2845
2881
|
const DEFAULT_SHELL = "sh";
|
|
2882
|
+
const DEFAULT_FORCE_KILL_AFTER = Duration.seconds(1);
|
|
2846
2883
|
const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(function* () {
|
|
2847
2884
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
2848
|
-
const
|
|
2849
|
-
|
|
2885
|
+
const forceKillAfter = options.forceKillAfter ?? DEFAULT_FORCE_KILL_AFTER;
|
|
2886
|
+
const spawn = (file, args, opts) => Effect.gen(function* () {
|
|
2887
|
+
const spawned = yield* spawner.spawn(ChildProcess.make(file, [...args], {
|
|
2850
2888
|
cwd: opts?.cwd,
|
|
2851
2889
|
env: {
|
|
2852
2890
|
...options.env,
|
|
@@ -2857,6 +2895,32 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
|
|
|
2857
2895
|
stdout: "pipe",
|
|
2858
2896
|
stderr: "pipe"
|
|
2859
2897
|
}));
|
|
2898
|
+
let openPipes = 2;
|
|
2899
|
+
const track = (stream) => stream.pipe(Stream.ensuring(Effect.sync(() => openPipes--)));
|
|
2900
|
+
const output = {
|
|
2901
|
+
stdout: track(spawned.stdout),
|
|
2902
|
+
stderr: track(spawned.stderr)
|
|
2903
|
+
};
|
|
2904
|
+
yield* Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.gen(function* () {
|
|
2905
|
+
const running = yield* spawned.isRunning.pipe(Effect.orElseSucceed(() => false));
|
|
2906
|
+
if (!running && openPipes === 0) return;
|
|
2907
|
+
if (running) yield* spawned.kill({ killSignal: "SIGTERM" }).pipe(Effect.timeoutOption(forceKillAfter), Effect.flatMap(Option.match({
|
|
2908
|
+
onSome: () => Effect.void,
|
|
2909
|
+
onNone: () => spawned.kill({ killSignal: "SIGKILL" })
|
|
2910
|
+
})), Effect.ignore);
|
|
2911
|
+
yield* Effect.sync(() => {
|
|
2912
|
+
try {
|
|
2913
|
+
process.kill(-Number(spawned.pid), "SIGKILL");
|
|
2914
|
+
} catch {}
|
|
2915
|
+
});
|
|
2916
|
+
}));
|
|
2917
|
+
return {
|
|
2918
|
+
exitCode: spawned.exitCode,
|
|
2919
|
+
...output
|
|
2920
|
+
};
|
|
2921
|
+
});
|
|
2922
|
+
const run = (label, file, args, opts) => Effect.scoped(Effect.gen(function* () {
|
|
2923
|
+
const handle = yield* spawn(file, args, opts);
|
|
2860
2924
|
return yield* Effect.all({
|
|
2861
2925
|
exitCode: handle.exitCode,
|
|
2862
2926
|
stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
|
|
@@ -2871,9 +2935,23 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
|
|
|
2871
2935
|
command: "",
|
|
2872
2936
|
cause: /* @__PURE__ */ new Error("execArgv requires a program")
|
|
2873
2937
|
})) : run(argv.join(" "), argv[0], argv.slice(1), opts));
|
|
2938
|
+
const stream = (command, opts) => Stream.unwrap(Effect.map(spawn(options.shell ?? DEFAULT_SHELL, ["-c", command], opts), (handle) => Stream.concat(Stream.merge(handle.stdout.pipe(Stream.map((bytes) => ({
|
|
2939
|
+
_tag: "stdout",
|
|
2940
|
+
bytes
|
|
2941
|
+
}))), handle.stderr.pipe(Stream.map((bytes) => ({
|
|
2942
|
+
_tag: "stderr",
|
|
2943
|
+
bytes
|
|
2944
|
+
})))), Stream.fromEffect(Effect.map(handle.exitCode, (exitCode) => ({
|
|
2945
|
+
_tag: "exit",
|
|
2946
|
+
exitCode
|
|
2947
|
+
})))))).pipe(Stream.mapError((cause) => new shell_exports.ShellError({
|
|
2948
|
+
command,
|
|
2949
|
+
cause
|
|
2950
|
+
})));
|
|
2874
2951
|
return shell_exports.Shell.of({
|
|
2875
2952
|
exec,
|
|
2876
|
-
execArgv
|
|
2953
|
+
execArgv,
|
|
2954
|
+
stream
|
|
2877
2955
|
});
|
|
2878
2956
|
}));
|
|
2879
2957
|
//#endregion
|
|
@@ -3258,7 +3336,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
|
|
|
3258
3336
|
id,
|
|
3259
3337
|
driver: driver_exports.SandboxDriver.Name.make("local"),
|
|
3260
3338
|
kind: "local",
|
|
3261
|
-
cwd: io_exports.SandboxIO.resolveMountCwd(hostDefaultCwd, mountOptions.cwd)
|
|
3339
|
+
cwd: io_exports.SandboxIO.resolveMountCwd(hostDefaultCwd, mountOptions.cwd),
|
|
3340
|
+
spillPath: io_exports.SandboxIO.DEFAULT_SPILL_PATH
|
|
3262
3341
|
};
|
|
3263
3342
|
else {
|
|
3264
3343
|
const row = yield* requireRow(id);
|
|
@@ -3280,7 +3359,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
|
|
|
3280
3359
|
id,
|
|
3281
3360
|
driver: attached.driver.name,
|
|
3282
3361
|
kind: row.kind,
|
|
3283
|
-
cwd: io_exports.SandboxIO.resolveMountCwd(attached.input.runtimeConfig.defaultCwd, mountOptions.cwd)
|
|
3362
|
+
cwd: io_exports.SandboxIO.resolveMountCwd(attached.input.runtimeConfig.defaultCwd, mountOptions.cwd),
|
|
3363
|
+
spillPath: attached.input.runtimeConfig.spillPath ?? io_exports.SandboxIO.DEFAULT_SPILL_PATH
|
|
3284
3364
|
};
|
|
3285
3365
|
const wasIdle = lease.held ? false : yield* gate.withPermits(1)(Effect.gen(function* () {
|
|
3286
3366
|
const latest = yield* requireRow(id);
|
|
@@ -3327,7 +3407,7 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
|
|
|
3327
3407
|
WHERE id = ${id}
|
|
3328
3408
|
`.pipe(Effect.orDie);
|
|
3329
3409
|
}
|
|
3330
|
-
return Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.Current, identity), Layer.succeed(io_exports.SandboxIO.FileSystem, filesystem_exports.SandboxFileSystem.withCwd(fs, identity.cwd)), Layer.succeed(io_exports.SandboxIO.Shell, (0, shell_exports.withCwd)(shell, identity.cwd)));
|
|
3410
|
+
return Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.Current, identity), Layer.succeed(io_exports.SandboxIO.FileSystem, filesystem_exports.SandboxFileSystem.withCwd(fs, identity.cwd)), Layer.succeed(io_exports.SandboxIO.Shell, (0, shell_exports.withCwd)(shell, identity.cwd))).pipe(io_exports.SandboxIO.withMutation);
|
|
3331
3411
|
}));
|
|
3332
3412
|
const mount = (id, mountOptions) => mountLayer(id, mountOptions);
|
|
3333
3413
|
const withMount = (id, use, mountOptions) => use.pipe(Effect.provide(mount(id, mountOptions)), Effect.scoped);
|
|
@@ -4277,7 +4357,7 @@ var Service$11 = class extends Context.Service()("@codeworksh/harness/repo/repo/
|
|
|
4277
4357
|
* `.git` suffix and surrounding slashes stripped, scp form accepted.
|
|
4278
4358
|
* `file:` urls and empty input yield undefined.
|
|
4279
4359
|
*/
|
|
4280
|
-
function normalize$
|
|
4360
|
+
function normalize$3(url) {
|
|
4281
4361
|
const value = url.trim();
|
|
4282
4362
|
if (!value) return void 0;
|
|
4283
4363
|
try {
|
|
@@ -4319,7 +4399,7 @@ const layer$14 = Layer.effect(Service$11, Effect.gen(function* () {
|
|
|
4319
4399
|
const marker = posix.join(repo.store, MARKER$1);
|
|
4320
4400
|
const pinned = (yield* readFileSafe(fs, marker))?.trim() || void 0;
|
|
4321
4401
|
const url = yield* git.remote(repo.directory);
|
|
4322
|
-
const normalized = url === void 0 ? void 0 : normalize$
|
|
4402
|
+
const normalized = url === void 0 ? void 0 : normalize$3(url);
|
|
4323
4403
|
const remote = normalized === void 0 ? void 0 : {
|
|
4324
4404
|
id: fast(`git:${normalized}`),
|
|
4325
4405
|
name: posix.basename(normalized)
|
|
@@ -5090,7 +5170,7 @@ function merge(base, ...patches) {
|
|
|
5090
5170
|
return Object.fromEntries(result);
|
|
5091
5171
|
}
|
|
5092
5172
|
/** Normalize nullable properties before schema validation, without changing array positions. */
|
|
5093
|
-
const normalize$
|
|
5173
|
+
const normalize$2 = copy;
|
|
5094
5174
|
//#endregion
|
|
5095
5175
|
//#region src/settings/settings.ts
|
|
5096
5176
|
var settings_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -5101,7 +5181,7 @@ var settings_exports = /* @__PURE__ */ __exportAll({
|
|
|
5101
5181
|
layer: () => layer$8,
|
|
5102
5182
|
load: () => load$3,
|
|
5103
5183
|
modules: () => modules,
|
|
5104
|
-
parse: () => parse$
|
|
5184
|
+
parse: () => parse$4,
|
|
5105
5185
|
paths: () => paths,
|
|
5106
5186
|
projectRoot: () => projectRoot,
|
|
5107
5187
|
userConfigDir: () => userConfigDir
|
|
@@ -5115,7 +5195,7 @@ const userConfigDir = (raw, hostCwd) => hostPath.resolve(hostCwd, expandTilde(ra
|
|
|
5115
5195
|
/** The settings files a directory may hold, in the order it is searched. */
|
|
5116
5196
|
const settingsIn = (directory) => [hostPath.join(directory, "settings.jsonc"), hostPath.join(directory, "settings.json")];
|
|
5117
5197
|
/** A directory and every ancestor above it, nearest first. */
|
|
5118
|
-
const ancestors = (from) => {
|
|
5198
|
+
const ancestors$1 = (from) => {
|
|
5119
5199
|
const chain = [];
|
|
5120
5200
|
for (let directory = from;; directory = hostPath.dirname(directory)) {
|
|
5121
5201
|
chain.push(directory);
|
|
@@ -5141,7 +5221,7 @@ const isDirectory = (path) => fileSystem.stat(path).pipe(Effect.map((info) => in
|
|
|
5141
5221
|
*/
|
|
5142
5222
|
const projectRoot = Effect.fn("Settings.projectRoot")(function* (from, home) {
|
|
5143
5223
|
const userConfig = hostPath.resolve(home);
|
|
5144
|
-
for (const directory of ancestors(hostPath.resolve(from))) {
|
|
5224
|
+
for (const directory of ancestors$1(hostPath.resolve(from))) {
|
|
5145
5225
|
const marker = hostPath.join(directory, appConfigDir);
|
|
5146
5226
|
if (hostPath.resolve(marker) === userConfig) continue;
|
|
5147
5227
|
if (yield* isDirectory(marker)) return directory;
|
|
@@ -5190,7 +5270,7 @@ const decodeFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (i
|
|
|
5190
5270
|
const message = "annotations" in issue ? issue.annotations?.message : void 0;
|
|
5191
5271
|
return typeof message === "string" ? message.replaceAll(/\s*\n\s*/g, " ") : "invalid value";
|
|
5192
5272
|
} });
|
|
5193
|
-
const parse$
|
|
5273
|
+
const parse$4 = Effect.fn("Settings.parse")(function* (path, source) {
|
|
5194
5274
|
const errors = [];
|
|
5195
5275
|
const json = parse(source, errors, {
|
|
5196
5276
|
allowTrailingComma: true,
|
|
@@ -5202,7 +5282,7 @@ const parse$2 = Effect.fn("Settings.parse")(function* (path, source) {
|
|
|
5202
5282
|
reason: "parse",
|
|
5203
5283
|
detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`
|
|
5204
5284
|
});
|
|
5205
|
-
const document = normalize$
|
|
5285
|
+
const document = normalize$2(json);
|
|
5206
5286
|
if (Predicate.isObject(document) && Predicate.isObject(json) && "plugins" in json) Object.assign(document, { plugins: json.plugins });
|
|
5207
5287
|
return yield* Schema.decodeUnknownEffect(schema_exports$3.Patch)(document).pipe(Effect.mapError((error) => new SettingsError({
|
|
5208
5288
|
path,
|
|
@@ -5238,7 +5318,7 @@ const attempt = (path) => fileSystem.readFileString(path).pipe(Effect.mapError((
|
|
|
5238
5318
|
path,
|
|
5239
5319
|
reason: "read",
|
|
5240
5320
|
detail: error.reason._tag
|
|
5241
|
-
})), Effect.flatMap((source) => parse$
|
|
5321
|
+
})), Effect.flatMap((source) => parse$4(path, source)));
|
|
5242
5322
|
/**
|
|
5243
5323
|
* One read of every layer. Exported for the harness constructor, which needs the plugin
|
|
5244
5324
|
* selection before any layer is built; everything else goes through `Service`.
|
|
@@ -5308,7 +5388,7 @@ var catalog_exports = /* @__PURE__ */ __exportAll({
|
|
|
5308
5388
|
Credentials: () => Credentials,
|
|
5309
5389
|
ModelCatalog: () => catalog_exports,
|
|
5310
5390
|
RefreshError: () => RefreshError,
|
|
5311
|
-
available: () => available,
|
|
5391
|
+
available: () => available$1,
|
|
5312
5392
|
effective: () => effective,
|
|
5313
5393
|
entry: () => entry,
|
|
5314
5394
|
filename: () => filename,
|
|
@@ -5489,7 +5569,7 @@ const oauthProtocols = /* @__PURE__ */ new Set(["openai-codex", "github-copilot"
|
|
|
5489
5569
|
* Presence, not validity: nothing is sent over the network, so a revoked key still counts. A
|
|
5490
5570
|
* client showing a model picker should still add the session's current model.
|
|
5491
5571
|
*/
|
|
5492
|
-
const available = Effect.fn("ModelCatalog.available")(function* (filter = {}) {
|
|
5572
|
+
const available$1 = Effect.fn("ModelCatalog.available")(function* (filter = {}) {
|
|
5493
5573
|
const { authFile } = yield* Credentials;
|
|
5494
5574
|
const settings = yield* Service$3;
|
|
5495
5575
|
const catalog = yield* effective((yield* settings.load(filter.hostDir)).models);
|
|
@@ -5558,11 +5638,25 @@ const content = (raw) => {
|
|
|
5558
5638
|
const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
|
|
5559
5639
|
return text.trim().length === 0 ? void 0 : text;
|
|
5560
5640
|
};
|
|
5561
|
-
|
|
5641
|
+
/** `directory` and every ancestor up to the sandbox root, closest first. */
|
|
5642
|
+
const ancestors = (directory) => {
|
|
5643
|
+
const chain = [];
|
|
5644
|
+
for (let current = directory;; current = posix.dirname(current)) {
|
|
5645
|
+
chain.push(current);
|
|
5646
|
+
if (posix.dirname(current) === current) return chain;
|
|
5647
|
+
}
|
|
5648
|
+
};
|
|
5649
|
+
/**
|
|
5650
|
+
* Which of `paths` are files. Absence is the expected answer and stays silent; any other failure
|
|
5651
|
+
* (permissions, a remote blip) is logged so a dropped file is not a mystery, then counts as absent.
|
|
5652
|
+
*/
|
|
5653
|
+
const present$1 = (fs, paths) => Effect.forEach(paths, (path) => fs.stat(path).pipe(Effect.map((stat) => stat.isFile), Effect.catch((error) => filesystem_exports.SandboxFileSystem.isNotFoundError(error.cause) ? Effect.succeed(false) : Effect.logWarning(`could not stat instruction file ${path}`, error.cause).pipe(Effect.as(false)))), { concurrency: "unbounded" }).pipe(Effect.map((hits) => new Set(paths.filter((_, index) => hits[index]))));
|
|
5654
|
+
/** A file that stats but does not read falls through to the next candidate. */
|
|
5655
|
+
const first = Effect.fnUntraced(function* (fs, directory, files) {
|
|
5562
5656
|
for (const name of names) {
|
|
5563
5657
|
const path = posix.join(directory, name);
|
|
5564
|
-
if (!
|
|
5565
|
-
const raw = yield* fs.readFile(path).pipe(Effect.tapError((
|
|
5658
|
+
if (!files.has(path)) continue;
|
|
5659
|
+
const raw = yield* fs.readFile(path).pipe(Effect.tapError((error) => Effect.logWarning(`could not read instruction file ${path}`, error.cause)), Effect.option);
|
|
5566
5660
|
if (Option.isSome(raw)) return {
|
|
5567
5661
|
name,
|
|
5568
5662
|
path,
|
|
@@ -5583,29 +5677,26 @@ const shadowing = (location) => {
|
|
|
5583
5677
|
const main = posix.dirname(store);
|
|
5584
5678
|
return location.space.location.startsWith(`${main}/`) ? main : void 0;
|
|
5585
5679
|
};
|
|
5680
|
+
/** One file per directory from the sandbox root down to the cwd, closest last so it has the final word. */
|
|
5681
|
+
const discover = Effect.fnUntraced(function* (fs, location) {
|
|
5682
|
+
const chain = ancestors(location.directory);
|
|
5683
|
+
const files = yield* present$1(fs, chain.flatMap((directory) => names.map((name) => posix.join(directory, name))));
|
|
5684
|
+
const found = yield* Effect.forEach(chain, (directory) => first(fs, directory, files), { concurrency: "unbounded" });
|
|
5685
|
+
const main = shadowing(location);
|
|
5686
|
+
const worktree = main === void 0 ? void 0 : found[chain.indexOf(location.space.location)];
|
|
5687
|
+
const walked = [];
|
|
5688
|
+
for (const [index, here] of found.entries()) {
|
|
5689
|
+
const shadowed = chain[index] === main && worktree !== void 0 && here?.name === worktree.name;
|
|
5690
|
+
if (here !== void 0 && !shadowed) walked.push(here);
|
|
5691
|
+
}
|
|
5692
|
+
return walked.reverse();
|
|
5693
|
+
});
|
|
5586
5694
|
const instructionPlugin = (0, plugin_exports$1.define)({
|
|
5587
5695
|
id: "codework.prompt.instruction",
|
|
5588
5696
|
kind: "prompt",
|
|
5589
5697
|
setup: Effect.fn("InstructionPlugin.setup")(function* (ctx) {
|
|
5590
5698
|
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
5591
|
-
const
|
|
5592
|
-
const at = Effect.fnUntraced(function* (directory) {
|
|
5593
|
-
if (!probed.has(directory)) probed.set(directory, yield* first(fs, directory));
|
|
5594
|
-
return probed.get(directory);
|
|
5595
|
-
});
|
|
5596
|
-
const walked = [];
|
|
5597
|
-
const main = shadowing(ctx.location);
|
|
5598
|
-
const worktree = main === void 0 ? void 0 : yield* at(ctx.location.space.location);
|
|
5599
|
-
let current = ctx.location.directory;
|
|
5600
|
-
while (true) {
|
|
5601
|
-
const here = yield* at(current);
|
|
5602
|
-
const shadowed = current === main && worktree !== void 0 && here?.name === worktree.name;
|
|
5603
|
-
if (here !== void 0 && !shadowed) walked.push(here);
|
|
5604
|
-
const parent = posix.dirname(current);
|
|
5605
|
-
if (parent === current) break;
|
|
5606
|
-
current = parent;
|
|
5607
|
-
}
|
|
5608
|
-
const files = walked.reverse().filter((file) => file.content !== void 0);
|
|
5699
|
+
const files = (yield* discover(fs, ctx.location)).filter((file) => file.content !== void 0);
|
|
5609
5700
|
if (files.length === 0) return;
|
|
5610
5701
|
const prompt = ctx.plugin.prompt;
|
|
5611
5702
|
prompt.sections.append(Section.ProjectContext, "Project-Specific Instructions & Guidelines:");
|
|
@@ -5626,6 +5717,17 @@ const instructionPlugin = (0, plugin_exports$1.define)({
|
|
|
5626
5717
|
*/
|
|
5627
5718
|
const DEFAULT_MAX_LINES = 2e3;
|
|
5628
5719
|
const DEFAULT_MAX_BYTES = 51200;
|
|
5720
|
+
/** Cut one line to `maxChars`, marking the cut. */
|
|
5721
|
+
function truncateLine(line, maxChars = 500) {
|
|
5722
|
+
if (line.length <= maxChars) return {
|
|
5723
|
+
text: line,
|
|
5724
|
+
wasTruncated: false
|
|
5725
|
+
};
|
|
5726
|
+
return {
|
|
5727
|
+
text: `${line.slice(0, maxChars)}... [truncated]`,
|
|
5728
|
+
wasTruncated: true
|
|
5729
|
+
};
|
|
5730
|
+
}
|
|
5629
5731
|
function splitLinesForCounting(content) {
|
|
5630
5732
|
if (content.length === 0) return [];
|
|
5631
5733
|
const lines = content.split("\n");
|
|
@@ -5639,6 +5741,74 @@ function formatSize(bytes) {
|
|
|
5639
5741
|
return `${(bytes / 1048576).toFixed(1)}MB`;
|
|
5640
5742
|
}
|
|
5641
5743
|
/**
|
|
5744
|
+
* Truncate content from the head (keep the first N lines/bytes). Suitable for
|
|
5745
|
+
* file reads where the beginning matters. Never returns partial lines; if the
|
|
5746
|
+
* first line alone exceeds the byte limit, returns empty content with
|
|
5747
|
+
* `firstLineExceedsLimit = true`.
|
|
5748
|
+
*/
|
|
5749
|
+
function truncateHead(content, options = {}) {
|
|
5750
|
+
const maxLines = options.maxLines ?? 2e3;
|
|
5751
|
+
const maxBytes = options.maxBytes ?? 51200;
|
|
5752
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
5753
|
+
const lines = splitLinesForCounting(content);
|
|
5754
|
+
const totalLines = lines.length;
|
|
5755
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) return {
|
|
5756
|
+
content,
|
|
5757
|
+
truncated: false,
|
|
5758
|
+
truncatedBy: null,
|
|
5759
|
+
totalLines,
|
|
5760
|
+
totalBytes,
|
|
5761
|
+
outputLines: totalLines,
|
|
5762
|
+
outputBytes: totalBytes,
|
|
5763
|
+
lastLinePartial: false,
|
|
5764
|
+
firstLineExceedsLimit: false,
|
|
5765
|
+
maxLines,
|
|
5766
|
+
maxBytes
|
|
5767
|
+
};
|
|
5768
|
+
if (Buffer.byteLength(lines[0] ?? "", "utf-8") > maxBytes) return {
|
|
5769
|
+
content: "",
|
|
5770
|
+
truncated: true,
|
|
5771
|
+
truncatedBy: "bytes",
|
|
5772
|
+
totalLines,
|
|
5773
|
+
totalBytes,
|
|
5774
|
+
outputLines: 0,
|
|
5775
|
+
outputBytes: 0,
|
|
5776
|
+
lastLinePartial: false,
|
|
5777
|
+
firstLineExceedsLimit: true,
|
|
5778
|
+
maxLines,
|
|
5779
|
+
maxBytes
|
|
5780
|
+
};
|
|
5781
|
+
const outputLinesArr = [];
|
|
5782
|
+
let outputBytesCount = 0;
|
|
5783
|
+
let truncatedBy = "lines";
|
|
5784
|
+
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
5785
|
+
const line = lines[i] ?? "";
|
|
5786
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
|
|
5787
|
+
if (outputBytesCount + lineBytes > maxBytes) {
|
|
5788
|
+
truncatedBy = "bytes";
|
|
5789
|
+
break;
|
|
5790
|
+
}
|
|
5791
|
+
outputLinesArr.push(line);
|
|
5792
|
+
outputBytesCount += lineBytes;
|
|
5793
|
+
}
|
|
5794
|
+
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) truncatedBy = "lines";
|
|
5795
|
+
const outputContent = outputLinesArr.join("\n");
|
|
5796
|
+
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
|
5797
|
+
return {
|
|
5798
|
+
content: outputContent,
|
|
5799
|
+
truncated: true,
|
|
5800
|
+
truncatedBy,
|
|
5801
|
+
totalLines,
|
|
5802
|
+
totalBytes,
|
|
5803
|
+
outputLines: outputLinesArr.length,
|
|
5804
|
+
outputBytes: finalOutputBytes,
|
|
5805
|
+
lastLinePartial: false,
|
|
5806
|
+
firstLineExceedsLimit: false,
|
|
5807
|
+
maxLines,
|
|
5808
|
+
maxBytes
|
|
5809
|
+
};
|
|
5810
|
+
}
|
|
5811
|
+
/**
|
|
5642
5812
|
* Truncate content from the tail (keep the last N lines/bytes). Suitable for
|
|
5643
5813
|
* bash output where the end matters (errors, final results). May return a
|
|
5644
5814
|
* partial first line if the last line of the original content exceeds the byte
|
|
@@ -5713,9 +5883,6 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
|
|
|
5713
5883
|
}
|
|
5714
5884
|
//#endregion
|
|
5715
5885
|
//#region src/tool/accumulator.ts
|
|
5716
|
-
function defaultTempFilePath(prefix, suffix) {
|
|
5717
|
-
return posix.join(tmpdir(), `${prefix}-${suffix}.log`);
|
|
5718
|
-
}
|
|
5719
5886
|
function byteLength(text) {
|
|
5720
5887
|
return Buffer.byteLength(text, "utf-8");
|
|
5721
5888
|
}
|
|
@@ -5723,9 +5890,7 @@ var Accumulator = class {
|
|
|
5723
5890
|
maxLines;
|
|
5724
5891
|
maxBytes;
|
|
5725
5892
|
maxRollingBytes;
|
|
5726
|
-
tempFilePrefix;
|
|
5727
5893
|
decoder = new TextDecoder();
|
|
5728
|
-
rawChunks = [];
|
|
5729
5894
|
tailText = "";
|
|
5730
5895
|
tailBytes = 0;
|
|
5731
5896
|
tailStartsAtLineBoundary = true;
|
|
@@ -5736,45 +5901,43 @@ var Accumulator = class {
|
|
|
5736
5901
|
currentLineBytes = 0;
|
|
5737
5902
|
hasOpenLine = false;
|
|
5738
5903
|
finished = false;
|
|
5739
|
-
tempFilePath;
|
|
5740
|
-
tempFile;
|
|
5741
5904
|
constructor(options = {}) {
|
|
5742
5905
|
this.maxLines = options.maxLines ?? 2e3;
|
|
5743
5906
|
this.maxBytes = options.maxBytes ?? 51200;
|
|
5744
5907
|
this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
append(data) {
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5908
|
+
}
|
|
5909
|
+
/** `rawBytes` is what `data` measured before a backend decoded it, when that differs. */
|
|
5910
|
+
append(data, rawBytes = data.length) {
|
|
5911
|
+
if (this.finished) throw new Error("cannot append to a finished output accumulator");
|
|
5912
|
+
this.totalRawBytes += rawBytes;
|
|
5913
|
+
this.appendDecodedText(this.decoder.decode(data, { stream: true }));
|
|
5914
|
+
}
|
|
5915
|
+
/**
|
|
5916
|
+
* Count output that was produced but not delivered — the start of the output,
|
|
5917
|
+
* before anything is appended. Unless it is known to end a line, the first
|
|
5918
|
+
* appended line is treated as partial and left out of snapshots.
|
|
5919
|
+
*/
|
|
5920
|
+
skip(bytes, newlines, endsLine) {
|
|
5921
|
+
if (bytes <= 0) return;
|
|
5922
|
+
this.totalRawBytes += bytes;
|
|
5923
|
+
this.totalDecodedBytes += bytes;
|
|
5924
|
+
this.completedLines += newlines;
|
|
5925
|
+
this.totalLines = this.completedLines;
|
|
5926
|
+
this.tailStartsAtLineBoundary = endsLine;
|
|
5759
5927
|
}
|
|
5760
5928
|
finish() {
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
this.appendDecodedText(this.decoder.decode());
|
|
5765
|
-
return Effect.gen({ self: this }, function* () {
|
|
5766
|
-
if (this.shouldUseTempFile()) yield* this.ensureTempFile();
|
|
5767
|
-
if (this.tempFile !== void 0) yield* this.tempFile.sync.pipe(Effect.orDie);
|
|
5768
|
-
});
|
|
5769
|
-
});
|
|
5929
|
+
if (this.finished) return;
|
|
5930
|
+
this.finished = true;
|
|
5931
|
+
this.appendDecodedText(this.decoder.decode());
|
|
5770
5932
|
}
|
|
5771
5933
|
snapshot() {
|
|
5772
5934
|
const tailTruncation = truncateTail(this.getSnapshotText(), {
|
|
5773
5935
|
maxLines: this.maxLines,
|
|
5774
5936
|
maxBytes: this.maxBytes
|
|
5775
5937
|
});
|
|
5776
|
-
const
|
|
5777
|
-
const
|
|
5938
|
+
const beyondLimits = this.totalLines > this.maxLines || this.totalRawBytes > this.maxBytes;
|
|
5939
|
+
const truncated = beyondLimits || tailTruncation.truncated;
|
|
5940
|
+
const truncatedBy = truncated ? tailTruncation.truncatedBy ?? (this.totalRawBytes > this.maxBytes ? "bytes" : "lines") : null;
|
|
5778
5941
|
const truncation = {
|
|
5779
5942
|
...tailTruncation,
|
|
5780
5943
|
truncated,
|
|
@@ -5787,7 +5950,7 @@ var Accumulator = class {
|
|
|
5787
5950
|
return {
|
|
5788
5951
|
content: truncation.content,
|
|
5789
5952
|
truncation,
|
|
5790
|
-
|
|
5953
|
+
beyondLimits
|
|
5791
5954
|
};
|
|
5792
5955
|
}
|
|
5793
5956
|
/** Bytes in the final (possibly unterminated) line — for the partial-line footer. */
|
|
@@ -5833,23 +5996,111 @@ var Accumulator = class {
|
|
|
5833
5996
|
getSnapshotText() {
|
|
5834
5997
|
if (this.tailStartsAtLineBoundary) return this.tailText;
|
|
5835
5998
|
const firstNewline = this.tailText.indexOf("\n");
|
|
5836
|
-
return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
|
|
5837
|
-
}
|
|
5838
|
-
shouldUseTempFile() {
|
|
5839
|
-
return this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines;
|
|
5840
|
-
}
|
|
5841
|
-
ensureTempFile() {
|
|
5842
|
-
if (this.tempFile !== void 0) return Effect.void;
|
|
5843
|
-
return Effect.gen({ self: this }, function* () {
|
|
5844
|
-
const suffix = Hex.encode(yield* crypto.randomBytes(8));
|
|
5845
|
-
this.tempFilePath = defaultTempFilePath(this.tempFilePrefix, suffix);
|
|
5846
|
-
this.tempFile = yield* fileSystem.open(this.tempFilePath, { flag: "w" });
|
|
5847
|
-
for (const chunk of this.rawChunks) yield* this.tempFile.writeAll(chunk);
|
|
5848
|
-
this.rawChunks = [];
|
|
5849
|
-
}).pipe(Effect.orDie);
|
|
5999
|
+
return firstNewline === -1 || firstNewline === this.tailText.length - 1 ? this.tailText : this.tailText.slice(firstNewline + 1);
|
|
5850
6000
|
}
|
|
5851
6001
|
};
|
|
5852
6002
|
//#endregion
|
|
6003
|
+
//#region src/tool/output.ts
|
|
6004
|
+
/** A new, unused output file path under `spillPath`. */
|
|
6005
|
+
const path$1 = (spillPath, prefix) => crypto.randomBytes(8).pipe(Effect.map((bytes) => posix.join(spillPath, `${prefix}-${Hex.encode(bytes)}.log`)), Effect.orDie);
|
|
6006
|
+
/** First line printed when the output file cannot be created; the command then runs unsaved. */
|
|
6007
|
+
const unsaved = (file) => `codework: output not saved, cannot create ${file}`;
|
|
6008
|
+
const HEADER = "codework-output";
|
|
6009
|
+
/** Twice the byte limit leaves room to find where the shown lines start. */
|
|
6010
|
+
const windowBytes = (limits) => limits.maxBytes * 2;
|
|
6011
|
+
/**
|
|
6012
|
+
* The wrapper. The command's combined output goes through `pipeline` into the
|
|
6013
|
+
* file; the file's size then decides whether it is kept, and `report` lines run
|
|
6014
|
+
* before an unkept file is removed.
|
|
6015
|
+
*/
|
|
6016
|
+
const script = (command, file, limits, pipeline, report) => [
|
|
6017
|
+
`f=${(0, shell_exports.quote)(file)}`,
|
|
6018
|
+
"run() (",
|
|
6019
|
+
command,
|
|
6020
|
+
")",
|
|
6021
|
+
`if mkdir -p ${(0, shell_exports.quote)(posix.dirname(file))} 2>/dev/null && (umask 077 2>/dev/null; set -C && : > "$f" && : > "$f.rc" && : > "$f.pid") 2>/dev/null; then`,
|
|
6022
|
+
" echo \"$$ $(ps -o pgid= -p $$ 2>/dev/null)\" > \"$f.pid\"",
|
|
6023
|
+
`\t${pipeline("{ run 2>&1; echo $? > \"$f.rc\"; }")}`,
|
|
6024
|
+
" rc=$(cat \"$f.rc\" 2>/dev/null); rm -f \"$f.rc\"",
|
|
6025
|
+
`\tb=$(wc -c "$f" | awk '{ print $1 }'); n=$(wc -l "$f" | awk '{ print $1 }'); l=$n`,
|
|
6026
|
+
`\t[ -s "$f" ] && [ "$(tail -c 1 "$f" | wc -l | awk '{ print $1 }')" = 0 ] && l=$((l + 1))`,
|
|
6027
|
+
`\tkeep=0; { [ "$b" -gt ${limits.maxBytes} ] || [ "$l" -gt ${limits.maxLines} ]; } && keep=1`,
|
|
6028
|
+
...report.map((line) => `\t${line}`),
|
|
6029
|
+
" [ \"$keep\" = 1 ] || rm -f \"$f\"",
|
|
6030
|
+
" rm -f \"$f.pid\"; exit \"${rc:-1}\"",
|
|
6031
|
+
"fi",
|
|
6032
|
+
"rm -f \"$f\" \"$f.rc\" \"$f.pid\" 2>/dev/null",
|
|
6033
|
+
`printf '%s\\n' ${(0, shell_exports.quote)(unsaved(file))}`,
|
|
6034
|
+
"run 2>&1"
|
|
6035
|
+
].join("\n");
|
|
6036
|
+
const header = new RegExp(`^${HEADER} (\\d+) (\\d+) ([01])(?: (\\d+))?$`, "m");
|
|
6037
|
+
const parse$3 = (match) => ({
|
|
6038
|
+
bytes: Number(match[1]),
|
|
6039
|
+
newlines: Number(match[2]),
|
|
6040
|
+
kept: match[3] === "1"
|
|
6041
|
+
});
|
|
6042
|
+
/**
|
|
6043
|
+
* For a backend that streams: all output still flows back as it is produced, and
|
|
6044
|
+
* a copy lands in the file. The report rides on stderr, the wrapper's own channel
|
|
6045
|
+
* since the command's is folded into stdout.
|
|
6046
|
+
*/
|
|
6047
|
+
const streaming = (command, file, limits) => script(command, file, limits, (source) => `${source} | tee -a "$f"`, [`printf '${HEADER} %s %s %s\\n' "$b" "$n" "$keep" >&2`]);
|
|
6048
|
+
/** Read {@link streaming}'s stderr: the report, if it got that far, and anything else the wrapper said. */
|
|
6049
|
+
const report = (stderr) => {
|
|
6050
|
+
const match = header.exec(stderr);
|
|
6051
|
+
if (match === null) return {
|
|
6052
|
+
report: void 0,
|
|
6053
|
+
rest: stderr
|
|
6054
|
+
};
|
|
6055
|
+
return {
|
|
6056
|
+
report: parse$3(match),
|
|
6057
|
+
rest: stderr.slice(0, match.index) + stderr.slice(match.index + match[0].length).replace(/^\n/, "")
|
|
6058
|
+
};
|
|
6059
|
+
};
|
|
6060
|
+
/**
|
|
6061
|
+
* For a backend that returns output only at the end: the output goes to the
|
|
6062
|
+
* file, and only a header — the report plus the window's raw size — and a tail
|
|
6063
|
+
* window come back. The bytes before the window are skipped, and counted.
|
|
6064
|
+
*/
|
|
6065
|
+
const buffered = (command, file, limits) => {
|
|
6066
|
+
const tail = `tail -n ${limits.maxLines} "$f" | tail -c ${windowBytes(limits)}`;
|
|
6067
|
+
return script(command, file, limits, (source) => `{ ${source} | cat; } >> "$f"`, [
|
|
6068
|
+
`w=$(${tail} | wc -c | awk '{ print $1 }')`,
|
|
6069
|
+
`printf '${HEADER} %s %s %s %s\\n' "$b" "$n" "$keep" "$w"`,
|
|
6070
|
+
tail
|
|
6071
|
+
]);
|
|
6072
|
+
};
|
|
6073
|
+
/** Read {@link buffered}'s result; `undefined` when the output was not saved and came back whole. */
|
|
6074
|
+
const window = (stdout) => {
|
|
6075
|
+
const newline = stdout.indexOf("\n");
|
|
6076
|
+
const match = header.exec(newline === -1 ? stdout : stdout.slice(0, newline));
|
|
6077
|
+
if (match === null || match[4] === void 0) return void 0;
|
|
6078
|
+
return {
|
|
6079
|
+
...parse$3(match),
|
|
6080
|
+
text: stdout.slice(newline + 1),
|
|
6081
|
+
textBytes: Number(match[4])
|
|
6082
|
+
};
|
|
6083
|
+
};
|
|
6084
|
+
/**
|
|
6085
|
+
* After a deadline or an interrupt the wrapper never reaches its own cleanup.
|
|
6086
|
+
* Kill what it started — its process group when it led one, else its process
|
|
6087
|
+
* tree, whole while the wrapper itself still runs — and remove the side files,
|
|
6088
|
+
* and the output file unless `keep`.
|
|
6089
|
+
*/
|
|
6090
|
+
const cleanup = (file, keep) => [
|
|
6091
|
+
`f=${(0, shell_exports.quote)(file)}`,
|
|
6092
|
+
"set -- $(cat \"$f.pid\" 2>/dev/null)",
|
|
6093
|
+
`tree() { echo "$1"; for c in $(ps -A -o pid= -o ppid= 2>/dev/null | awk -v p="$1" '$2 == p { print $1 }'); do tree "$c"; done; }`,
|
|
6094
|
+
"if [ -n \"${1:-}\" ]; then",
|
|
6095
|
+
" pids=$(tree \"$1\")",
|
|
6096
|
+
" [ \"${2:-$1}\" = \"$1\" ] && kill -s KILL -- \"-$1\" 2>/dev/null",
|
|
6097
|
+
" kill -9 $pids 2>/dev/null",
|
|
6098
|
+
"fi",
|
|
6099
|
+
`rm -f -- ${keep ? "\"$f.rc\" \"$f.pid\"" : "\"$f\" \"$f.rc\" \"$f.pid\""}`
|
|
6100
|
+
].join("\n");
|
|
6101
|
+
/** Remove an output file that {@link cleanup} kept. */
|
|
6102
|
+
const remove$1 = (file) => `rm -f -- ${(0, shell_exports.quote)(file)}`;
|
|
6103
|
+
//#endregion
|
|
5853
6104
|
//#region src/tool/progress.ts
|
|
5854
6105
|
var progress_exports = /* @__PURE__ */ __exportAll({});
|
|
5855
6106
|
import * as import__codeworksh_plugin_tool_progress from "@codeworksh/plugin/tool/progress";
|
|
@@ -5859,16 +6110,11 @@ __reExport(progress_exports, import__codeworksh_plugin_tool_progress);
|
|
|
5859
6110
|
/**
|
|
5860
6111
|
* `ToolShell` — the tool-owned, cancellable command-execution capability the bash tool depends on.
|
|
5861
6112
|
*
|
|
5862
|
-
* It deliberately does **not** reuse `sandbox/Shell` directly:
|
|
5863
|
-
*
|
|
5864
|
-
*
|
|
5865
|
-
* mid-flight
|
|
5866
|
-
*
|
|
5867
|
-
*
|
|
5868
|
-
* Two modes, and Local has two variants:
|
|
5869
|
-
* - Local / full OS shell → {@link local} (default)
|
|
5870
|
-
* - Local / just-bash → {@link fromSandboxShell} over `EnvBash`
|
|
5871
|
-
* - Remote / provider → {@link fromSandboxShell} over `EnvDaytona` / `EnvVercel`
|
|
6113
|
+
* It deliberately does **not** reuse `sandbox/Shell` directly: `sandbox/Shell`
|
|
6114
|
+
* models no timeout, and `ToolShell` makes it part of the contract. Every
|
|
6115
|
+
* backend reaches it through {@link fromSandboxShell}; the process dies
|
|
6116
|
+
* mid-flight where the backend can kill it (host, Vercel) and the call returns
|
|
6117
|
+
* at the deadline either way.
|
|
5872
6118
|
*/
|
|
5873
6119
|
/** Infra/spawn failure — not model-actionable; handlers should treat it as a defect. */
|
|
5874
6120
|
var ToolShellError = class extends Schema.TaggedError()("ToolShellError", {
|
|
@@ -5923,6 +6169,7 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
|
|
|
5923
6169
|
exitCode: chunk.exitCode
|
|
5924
6170
|
} : {
|
|
5925
6171
|
_tag: "Output",
|
|
6172
|
+
channel: chunk._tag,
|
|
5926
6173
|
bytes: chunk.bytes
|
|
5927
6174
|
}), Stream.mapError((cause) => new ToolShellError({
|
|
5928
6175
|
command,
|
|
@@ -5933,7 +6180,6 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
|
|
|
5933
6180
|
stream
|
|
5934
6181
|
} : { exec });
|
|
5935
6182
|
}));
|
|
5936
|
-
Duration.seconds(1);
|
|
5937
6183
|
//#endregion
|
|
5938
6184
|
//#region src/tool/tool.ts
|
|
5939
6185
|
var tool_exports = /* @__PURE__ */ __exportAll({});
|
|
@@ -5943,28 +6189,32 @@ __reExport(tool_exports, import__codeworksh_plugin_tool);
|
|
|
5943
6189
|
//#region src/plugin/builtin/tool/bash.ts
|
|
5944
6190
|
/**
|
|
5945
6191
|
* The self-contained bash plugin — a worked example for tool plugins. The definition is pure data;
|
|
5946
|
-
* the handler depends only on {@link ToolShell} / {@link ToolProgress}
|
|
5947
|
-
* `sandbox/Shell`, so the backend (
|
|
5948
|
-
* swapped by changing the provided Layer with no change to the tool.
|
|
6192
|
+
* the handler depends only on {@link ToolShell} / {@link ToolProgress} and the mount's
|
|
6193
|
+
* {@link SandboxIO.Current}, never `sandbox/Shell`, so the backend (host / just-bash / remote
|
|
6194
|
+
* provider) is swapped by changing the provided Layer with no change to the tool.
|
|
5949
6195
|
*
|
|
5950
|
-
*
|
|
5951
|
-
*
|
|
5952
|
-
*
|
|
5953
|
-
*
|
|
5954
|
-
*
|
|
5955
|
-
* and is reported live via {@link ToolProgress}; on
|
|
5956
|
-
* produced so far is preserved.
|
|
6196
|
+
* The command runs wrapped (`Output`) so its full output is written to a file inside the sandbox,
|
|
6197
|
+
* kept only when it exceeds the display limits. Two paths, picked by capability:
|
|
6198
|
+
* - **variant A (buffered)** otherwise: the sandbox returns only the tail of the output with its
|
|
6199
|
+
* size, so a huge output never crosses to the harness.
|
|
6200
|
+
* - **variant B (streaming)** when the backend offers `ToolShell.stream`: output flows through
|
|
6201
|
+
* an {@link Accumulator} (bounded memory) and is reported live via {@link ToolProgress}; on
|
|
6202
|
+
* timeout the partial output produced so far is preserved.
|
|
5957
6203
|
*
|
|
5958
6204
|
* Success and failure carry the *same* structured shape
|
|
5959
6205
|
* (`output`, `exitCode`, `truncated`, `fullOutputPath`)
|
|
5960
6206
|
* — a non-zero exit is just `exitCode !== 0`, not a different kind of result.
|
|
5961
6207
|
*/
|
|
6208
|
+
const limits = {
|
|
6209
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
6210
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
6211
|
+
};
|
|
5962
6212
|
const BashParams = Schema.Struct({
|
|
5963
6213
|
command: Schema.String.annotate({ description: "The bash command to execute." }),
|
|
5964
6214
|
timeout: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ description: "Optional timeout in seconds (must be greater than 0)." }))
|
|
5965
6215
|
});
|
|
5966
6216
|
const outputFields = {
|
|
5967
|
-
/** Combined stdout + stderr, truncated for display (full output is
|
|
6217
|
+
/** Combined stdout + stderr, truncated for display (full output is in the sandbox when `truncated`). */
|
|
5968
6218
|
output: Schema.String,
|
|
5969
6219
|
truncated: Schema.Boolean,
|
|
5970
6220
|
fullOutputPath: Schema.optional(Schema.String)
|
|
@@ -5984,21 +6234,30 @@ var BashTimedOut = class extends Schema.TaggedError()("BashTimedOut", {
|
|
|
5984
6234
|
timeoutSeconds: Schema.Finite
|
|
5985
6235
|
}) {};
|
|
5986
6236
|
const BashFailure = Schema.Union([BashFailed, BashTimedOut]);
|
|
6237
|
+
/** The reason for a failure, after any output: the model sees nothing else of `details`. */
|
|
6238
|
+
const withStatus = (failure) => {
|
|
6239
|
+
const status = failure._tag === "BashTimedOut" ? `Command timed out after ${failure.timeoutSeconds} seconds` : failure.exitCode === -1 ? "Command terminated without an exit code" : `Command exited with code ${failure.exitCode}`;
|
|
6240
|
+
return failure.output ? `${failure.output.replace(/\n$/, "")}\n\n${status}` : status;
|
|
6241
|
+
};
|
|
5987
6242
|
const bashDef = tool_exports.define({
|
|
5988
6243
|
name: "bash",
|
|
5989
6244
|
label: "bash",
|
|
5990
6245
|
promptSnippet: "Execute bash commands (ls, grep, find, etc.).",
|
|
5991
|
-
description: `Execute a bash command in the working directory and return its combined stdout/stderr output. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES}
|
|
6246
|
+
description: `Execute a bash command in the working directory and return its combined stdout/stderr output. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first); when truncated, a note says which lines are shown and gives the path of a file holding the full output when one was saved. A non-zero exit code is reported as an error carrying the captured output.`,
|
|
5992
6247
|
parameters: BashParams,
|
|
6248
|
+
constrainedSampling: {
|
|
6249
|
+
type: "json_schema",
|
|
6250
|
+
strict: "prefer"
|
|
6251
|
+
},
|
|
5993
6252
|
success: BashSuccess,
|
|
5994
6253
|
failure: BashFailure,
|
|
5995
6254
|
encodeContent: (success) => [{
|
|
5996
6255
|
type: "text",
|
|
5997
|
-
text: success.output
|
|
6256
|
+
text: success.output || "(no output)"
|
|
5998
6257
|
}],
|
|
5999
6258
|
encodeFailureContent: (failure) => [{
|
|
6000
6259
|
type: "text",
|
|
6001
|
-
text: failure
|
|
6260
|
+
text: withStatus(failure)
|
|
6002
6261
|
}]
|
|
6003
6262
|
});
|
|
6004
6263
|
/** Combine stdout and stderr into one stream of text, stderr after stdout. */
|
|
@@ -6018,66 +6277,78 @@ const footer = (t, fullOutputPath, lastLineBytes) => {
|
|
|
6018
6277
|
if (t.truncatedBy === "lines") return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`;
|
|
6019
6278
|
return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`;
|
|
6020
6279
|
};
|
|
6021
|
-
/**
|
|
6022
|
-
const
|
|
6023
|
-
const suffix = Hex.encode(yield* crypto.randomBytes(6));
|
|
6024
|
-
const path = posix.join(tmpdir(), `codework-bash-${suffix}.log`);
|
|
6025
|
-
yield* fileSystem.writeFileString(path, content);
|
|
6026
|
-
return path;
|
|
6027
|
-
}).pipe(Effect.orElseSucceed(() => void 0));
|
|
6028
|
-
/** Variant A: truncate the buffered output once, spilling the full output if truncated. */
|
|
6029
|
-
const presentBuffered = (combined) => Effect.gen(function* () {
|
|
6030
|
-
const t = truncateTail(combined);
|
|
6031
|
-
if (!t.truncated) return {
|
|
6032
|
-
output: t.content,
|
|
6033
|
-
truncated: false
|
|
6034
|
-
};
|
|
6035
|
-
const fullOutputPath = yield* spillToTempFile(combined);
|
|
6036
|
-
return {
|
|
6037
|
-
output: t.content + footer(t, fullOutputPath),
|
|
6038
|
-
truncated: true,
|
|
6039
|
-
...fullOutputPath !== void 0 ? { fullOutputPath } : {}
|
|
6040
|
-
};
|
|
6041
|
-
});
|
|
6042
|
-
/** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */
|
|
6043
|
-
const presentSnapshot = (snap, lastLineBytes) => {
|
|
6280
|
+
/** Present a snapshot; `saved` is the output file, when the sandbox kept one. */
|
|
6281
|
+
const present = (snap, lastLineBytes, saved) => {
|
|
6044
6282
|
if (!snap.truncation.truncated) return {
|
|
6045
6283
|
output: snap.content,
|
|
6046
6284
|
truncated: false
|
|
6047
6285
|
};
|
|
6048
6286
|
return {
|
|
6049
|
-
output: snap.content + footer(snap.truncation,
|
|
6287
|
+
output: snap.content.replace(/\n$/, "") + footer(snap.truncation, saved, lastLineBytes),
|
|
6050
6288
|
truncated: true,
|
|
6051
|
-
...
|
|
6289
|
+
...saved !== void 0 ? { fullOutputPath: saved } : {}
|
|
6052
6290
|
};
|
|
6053
6291
|
};
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6292
|
+
const utf8 = new TextEncoder();
|
|
6293
|
+
/**
|
|
6294
|
+
* A command cut off by its deadline never reached the wrapper's own cleanup: kill
|
|
6295
|
+
* what is left of it and remove its side files, keeping the output file only when
|
|
6296
|
+
* the result points at it.
|
|
6297
|
+
*/
|
|
6298
|
+
const cleanUp = (shell, file, presented) => sweep(shell, cleanup(file, presented.fullOutputPath !== void 0));
|
|
6299
|
+
/** Best-effort housekeeping script: bounded, and never a failure of the tool. */
|
|
6300
|
+
const sweep = (shell, script) => shell.exec(script, { timeout: Duration.seconds(5) }).pipe(Effect.ignore);
|
|
6301
|
+
/** Variant A — buffered: one `exec` that returns the tail of the output and its size. No live progress. */
|
|
6302
|
+
const runBuffered = (shell, params, file) => Effect.gen(function* () {
|
|
6303
|
+
const result = yield* shell.exec(buffered(params.command, file, limits), params.timeout !== void 0 ? { timeout: Duration.seconds(params.timeout) } : void 0).pipe(Effect.catchTags({
|
|
6304
|
+
ToolShellTimeout: (timeout) => cleanUp(shell, file, {
|
|
6305
|
+
output: "",
|
|
6306
|
+
truncated: false
|
|
6307
|
+
}).pipe(Effect.andThen(Effect.fail(new BashTimedOut({
|
|
6058
6308
|
timeoutSeconds: timeout.timeoutMillis / 1e3,
|
|
6059
6309
|
output: "",
|
|
6060
6310
|
truncated: false
|
|
6061
|
-
})),
|
|
6311
|
+
})))),
|
|
6062
6312
|
ToolShellError: (cause) => Effect.die(cause)
|
|
6063
6313
|
}));
|
|
6064
|
-
const
|
|
6314
|
+
const acc = new Accumulator(limits);
|
|
6315
|
+
const combined = combineOutput(result.stdout, result.stderr);
|
|
6316
|
+
const window$1 = window(combined);
|
|
6317
|
+
if (window$1 === void 0) acc.append(utf8.encode(combined));
|
|
6318
|
+
else {
|
|
6319
|
+
acc.skip(window$1.bytes - window$1.textBytes, window$1.newlines - countNewlines(window$1.text), window$1.textBytes < windowBytes(limits));
|
|
6320
|
+
acc.append(utf8.encode(window$1.text), window$1.textBytes);
|
|
6321
|
+
}
|
|
6322
|
+
acc.finish();
|
|
6323
|
+
const presented = present(acc.snapshot(), acc.getLastLineBytes(), window$1?.kept === true ? file : void 0);
|
|
6065
6324
|
if (result.exitCode !== 0) return yield* new BashFailed({
|
|
6066
6325
|
exitCode: result.exitCode,
|
|
6067
|
-
...
|
|
6326
|
+
...presented
|
|
6068
6327
|
});
|
|
6069
6328
|
return {
|
|
6070
6329
|
exitCode: result.exitCode,
|
|
6071
|
-
...
|
|
6330
|
+
...presented
|
|
6072
6331
|
};
|
|
6073
6332
|
});
|
|
6333
|
+
const countNewlines = (text) => {
|
|
6334
|
+
let count = 0;
|
|
6335
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++;
|
|
6336
|
+
return count;
|
|
6337
|
+
};
|
|
6074
6338
|
/** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */
|
|
6075
|
-
const runStreaming = (stream, params) => Effect.
|
|
6076
|
-
const acc = new Accumulator(
|
|
6339
|
+
const runStreaming = (shell, stream, params, file) => Effect.gen(function* () {
|
|
6340
|
+
const acc = new Accumulator(limits);
|
|
6077
6341
|
const progress = yield* progress_exports.ToolProgress;
|
|
6078
6342
|
const exitCode = yield* Ref.make(null);
|
|
6079
|
-
const
|
|
6080
|
-
|
|
6343
|
+
const notice = unsaved(file);
|
|
6344
|
+
let head = "";
|
|
6345
|
+
const control = new TextDecoder();
|
|
6346
|
+
let said = "";
|
|
6347
|
+
const consume = stream(streaming(params.command, file, limits)).pipe(Stream.runForEach((event) => event._tag === "Exit" ? Ref.set(exitCode, event.exitCode) : event.channel === "stderr" ? Effect.sync(() => {
|
|
6348
|
+
said += control.decode(event.bytes, { stream: true });
|
|
6349
|
+
}) : Effect.gen(function* () {
|
|
6350
|
+
if (head.length < notice.length) head += new TextDecoder().decode(event.bytes.slice(0, notice.length));
|
|
6351
|
+
acc.append(event.bytes);
|
|
6081
6352
|
yield* progress.report({ content: [{
|
|
6082
6353
|
type: "text",
|
|
6083
6354
|
text: acc.snapshot().content
|
|
@@ -6085,28 +6356,45 @@ const runStreaming = (stream, params) => Effect.scoped(Effect.gen(function* () {
|
|
|
6085
6356
|
})), Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)));
|
|
6086
6357
|
let timedOutAfter;
|
|
6087
6358
|
if (params.timeout !== void 0) {
|
|
6088
|
-
const
|
|
6089
|
-
|
|
6359
|
+
const fiber = yield* Effect.forkChild(consume);
|
|
6360
|
+
const finished = yield* Fiber.await(fiber).pipe(Effect.timeoutOption(Duration.seconds(params.timeout)));
|
|
6361
|
+
if (Option.isSome(finished)) yield* Fiber.join(fiber);
|
|
6362
|
+
else {
|
|
6363
|
+
timedOutAfter = params.timeout;
|
|
6364
|
+
yield* sweep(shell, cleanup(file, true));
|
|
6365
|
+
yield* Fiber.interrupt(fiber);
|
|
6366
|
+
}
|
|
6090
6367
|
} else yield* consume;
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6368
|
+
const { report: report$1, rest } = report(said + control.decode());
|
|
6369
|
+
if (rest.length > 0) acc.append(utf8.encode(rest));
|
|
6370
|
+
acc.finish();
|
|
6371
|
+
const snapshot = acc.snapshot();
|
|
6372
|
+
const kept = report$1 !== void 0 ? report$1.kept : snapshot.beyondLimits && !head.startsWith(notice);
|
|
6373
|
+
const presented = present(snapshot, acc.getLastLineBytes(), kept ? file : void 0);
|
|
6374
|
+
if (timedOutAfter !== void 0) {
|
|
6375
|
+
if (!kept) yield* sweep(shell, remove$1(file));
|
|
6376
|
+
return yield* new BashTimedOut({
|
|
6377
|
+
timeoutSeconds: timedOutAfter,
|
|
6378
|
+
...presented
|
|
6379
|
+
});
|
|
6380
|
+
}
|
|
6097
6381
|
const code = yield* Ref.get(exitCode);
|
|
6098
6382
|
if (code === null || code !== 0) return yield* new BashFailed({
|
|
6099
6383
|
exitCode: code ?? -1,
|
|
6100
|
-
...
|
|
6384
|
+
...presented
|
|
6101
6385
|
});
|
|
6102
6386
|
return {
|
|
6103
6387
|
exitCode: code,
|
|
6104
|
-
...
|
|
6388
|
+
...presented
|
|
6105
6389
|
};
|
|
6106
|
-
})
|
|
6390
|
+
});
|
|
6107
6391
|
const bashHandler = (params) => Effect.gen(function* () {
|
|
6108
6392
|
const shell = yield* ToolShell;
|
|
6109
|
-
|
|
6393
|
+
const file = yield* path$1((yield* io_exports.SandboxIO.Current).spillPath, "codework-bash");
|
|
6394
|
+
return yield* (shell.stream !== void 0 ? runStreaming(shell, shell.stream, params, file) : runBuffered(shell, params, file)).pipe(Effect.onInterrupt(() => cleanUp(shell, file, {
|
|
6395
|
+
output: "",
|
|
6396
|
+
truncated: false
|
|
6397
|
+
})));
|
|
6110
6398
|
});
|
|
6111
6399
|
/** The bash tool: definition + handler, wired the testable (def/exec split) way. */
|
|
6112
6400
|
const bashTool = tool_exports.implement(bashDef, bashHandler);
|
|
@@ -6115,18 +6403,1402 @@ const bashPlugin = (0, plugin_exports$1.define)({
|
|
|
6115
6403
|
kind: "tool",
|
|
6116
6404
|
setup: Effect.fn("BashPlugin.setup")(function* (ctx) {
|
|
6117
6405
|
const shell = yield* io_exports.SandboxIO.Shell;
|
|
6118
|
-
const
|
|
6406
|
+
const current = yield* io_exports.SandboxIO.Current;
|
|
6407
|
+
const mounted = Layer.merge(fromSandboxShell.pipe(Layer.provide(Layer.succeed(io_exports.SandboxIO.Shell, shell))), Layer.succeed(io_exports.SandboxIO.Current, current));
|
|
6119
6408
|
ctx.plugin.tools.add(tool_exports.provide(bashTool, mounted));
|
|
6120
6409
|
})
|
|
6121
6410
|
});
|
|
6122
6411
|
//#endregion
|
|
6412
|
+
//#region src/tool/diff.ts
|
|
6413
|
+
const detectLineEnding = (content) => {
|
|
6414
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
6415
|
+
const lfIdx = content.indexOf("\n");
|
|
6416
|
+
if (lfIdx === -1 || crlfIdx === -1) return "\n";
|
|
6417
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
6418
|
+
};
|
|
6419
|
+
const normalizeToLF = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
6420
|
+
const restoreLineEndings = (text, ending) => ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
6421
|
+
/** Strip a UTF-8 BOM if present; the BOM is written back after editing. */
|
|
6422
|
+
const stripBom = (content) => content.startsWith("") ? {
|
|
6423
|
+
bom: "",
|
|
6424
|
+
text: content.slice(1)
|
|
6425
|
+
} : {
|
|
6426
|
+
bom: "",
|
|
6427
|
+
text: content
|
|
6428
|
+
};
|
|
6429
|
+
/**
|
|
6430
|
+
* The form both sides are compared in when the exact text is not found:
|
|
6431
|
+
* NFKC, no trailing whitespace per line, ASCII quotes, dashes and spaces.
|
|
6432
|
+
*/
|
|
6433
|
+
const normalizeForFuzzyMatch = (text) => text.normalize("NFKC").split("\n").map((line) => line.trimEnd()).join("\n").replace(/[‘’‚‛]/g, "'").replace(/[“”„‟]/g, "\"").replace(/[‐‑‒–—―−]/g, "-").replace(/[ - ]/g, " ");
|
|
6434
|
+
const splitLinesWithEndings = (content) => content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
6435
|
+
const getLineSpans = (content) => {
|
|
6436
|
+
let offset = 0;
|
|
6437
|
+
return splitLinesWithEndings(content).map((line) => {
|
|
6438
|
+
const span = {
|
|
6439
|
+
start: offset,
|
|
6440
|
+
end: offset + line.length
|
|
6441
|
+
};
|
|
6442
|
+
offset = span.end;
|
|
6443
|
+
return span;
|
|
6444
|
+
});
|
|
6445
|
+
};
|
|
6446
|
+
const getReplacementLineRange = (lines, replacement) => {
|
|
6447
|
+
const replacementStart = replacement.matchIndex;
|
|
6448
|
+
const replacementEnd = replacement.matchIndex + replacement.matchLength;
|
|
6449
|
+
const startLine = lines.findIndex((line) => replacementStart >= line.start && replacementStart < line.end);
|
|
6450
|
+
if (startLine === -1) throw new Error("Replacement range is outside the base content.");
|
|
6451
|
+
let endLine = startLine;
|
|
6452
|
+
while (endLine < lines.length && lines[endLine].end < replacementEnd) endLine++;
|
|
6453
|
+
if (endLine >= lines.length) throw new Error("Replacement range is outside the base content.");
|
|
6454
|
+
return {
|
|
6455
|
+
startLine,
|
|
6456
|
+
endLine: endLine + 1
|
|
6457
|
+
};
|
|
6458
|
+
};
|
|
6459
|
+
const applyReplacements = (content, replacements, offset = 0) => {
|
|
6460
|
+
let result = content;
|
|
6461
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
6462
|
+
const replacement = replacements[i];
|
|
6463
|
+
const matchIndex = replacement.matchIndex - offset;
|
|
6464
|
+
result = result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
|
|
6465
|
+
}
|
|
6466
|
+
return result;
|
|
6467
|
+
};
|
|
6468
|
+
/**
|
|
6469
|
+
* Apply replacements matched against `baseContent`, a normalized view of
|
|
6470
|
+
* `originalContent`: the lines a replacement touches are rewritten from the
|
|
6471
|
+
* normalized base, every other line is copied back from the original. The
|
|
6472
|
+
* replacement ranges drive this, so duplicate normalized lines cannot be
|
|
6473
|
+
* aligned to the wrong occurrence.
|
|
6474
|
+
*/
|
|
6475
|
+
const applyReplacementsPreservingUnchangedLines = (originalContent, baseContent, replacements) => {
|
|
6476
|
+
const originalLines = splitLinesWithEndings(originalContent);
|
|
6477
|
+
const baseLines = getLineSpans(baseContent);
|
|
6478
|
+
if (originalLines.length !== baseLines.length) throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
|
|
6479
|
+
const groups = [];
|
|
6480
|
+
for (const replacement of [...replacements].sort((a, b) => a.matchIndex - b.matchIndex)) {
|
|
6481
|
+
const range = getReplacementLineRange(baseLines, replacement);
|
|
6482
|
+
const current = groups[groups.length - 1];
|
|
6483
|
+
if (current && range.startLine < current.endLine) {
|
|
6484
|
+
current.endLine = Math.max(current.endLine, range.endLine);
|
|
6485
|
+
current.replacements.push(replacement);
|
|
6486
|
+
continue;
|
|
6487
|
+
}
|
|
6488
|
+
groups.push({
|
|
6489
|
+
...range,
|
|
6490
|
+
replacements: [replacement]
|
|
6491
|
+
});
|
|
6492
|
+
}
|
|
6493
|
+
let originalLineIndex = 0;
|
|
6494
|
+
let result = "";
|
|
6495
|
+
for (const group of groups) {
|
|
6496
|
+
result += originalLines.slice(originalLineIndex, group.startLine).join("");
|
|
6497
|
+
const groupStartOffset = baseLines[group.startLine].start;
|
|
6498
|
+
const groupEndOffset = baseLines[group.endLine - 1].end;
|
|
6499
|
+
result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
|
|
6500
|
+
originalLineIndex = group.endLine;
|
|
6501
|
+
}
|
|
6502
|
+
return result + originalLines.slice(originalLineIndex).join("");
|
|
6503
|
+
};
|
|
6504
|
+
/** Find `oldText`, exactly first, then in fuzzy-normalized space. */
|
|
6505
|
+
const fuzzyFindText = (content, oldText) => {
|
|
6506
|
+
const exactIndex = content.indexOf(oldText);
|
|
6507
|
+
if (exactIndex !== -1) return {
|
|
6508
|
+
found: true,
|
|
6509
|
+
index: exactIndex,
|
|
6510
|
+
matchLength: oldText.length,
|
|
6511
|
+
usedFuzzyMatch: false,
|
|
6512
|
+
contentForReplacement: content
|
|
6513
|
+
};
|
|
6514
|
+
const fuzzyContent = normalizeForFuzzyMatch(content);
|
|
6515
|
+
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
6516
|
+
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
|
6517
|
+
if (fuzzyIndex === -1) return {
|
|
6518
|
+
found: false,
|
|
6519
|
+
index: -1,
|
|
6520
|
+
matchLength: 0,
|
|
6521
|
+
usedFuzzyMatch: false,
|
|
6522
|
+
contentForReplacement: content
|
|
6523
|
+
};
|
|
6524
|
+
return {
|
|
6525
|
+
found: true,
|
|
6526
|
+
index: fuzzyIndex,
|
|
6527
|
+
matchLength: fuzzyOldText.length,
|
|
6528
|
+
usedFuzzyMatch: true,
|
|
6529
|
+
contentForReplacement: fuzzyContent
|
|
6530
|
+
};
|
|
6531
|
+
};
|
|
6532
|
+
const countOccurrences = (content, oldText) => normalizeForFuzzyMatch(content).split(normalizeForFuzzyMatch(oldText)).length - 1;
|
|
6533
|
+
const notFound$1 = (path, editIndex, totalEdits) => totalEdits === 1 ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.` : `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`;
|
|
6534
|
+
const duplicate = (path, editIndex, totalEdits, occurrences) => totalEdits === 1 ? `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.` : `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
|
|
6535
|
+
const emptyOldText = (path, editIndex, totalEdits) => totalEdits === 1 ? `oldText must not be empty in ${path}.` : `edits[${editIndex}].oldText must not be empty in ${path}.`;
|
|
6536
|
+
const noChange = (path, totalEdits) => totalEdits === 1 ? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.` : `No changes made to ${path}. The replacements produced identical content.`;
|
|
6537
|
+
/**
|
|
6538
|
+
* Apply exact-text replacements to LF-normalized content. Every edit is matched
|
|
6539
|
+
* against the same original, then applied last to first so offsets stay valid.
|
|
6540
|
+
* If any edit needs fuzzy matching, all run in fuzzy space and only the touched
|
|
6541
|
+
* lines are written from it. Throws, with the message the model reads, when an
|
|
6542
|
+
* edit is empty, missing, ambiguous, overlapping, or changes nothing.
|
|
6543
|
+
*/
|
|
6544
|
+
const applyEditsToNormalizedContent = (normalizedContent, edits, path) => {
|
|
6545
|
+
const normalizedEdits = edits.map((edit) => ({
|
|
6546
|
+
oldText: normalizeToLF(edit.oldText),
|
|
6547
|
+
newText: normalizeToLF(edit.newText)
|
|
6548
|
+
}));
|
|
6549
|
+
normalizedEdits.forEach((edit, i) => {
|
|
6550
|
+
if (edit.oldText.length === 0) throw new Error(emptyOldText(path, i, normalizedEdits.length));
|
|
6551
|
+
});
|
|
6552
|
+
const usedFuzzyMatch = normalizedEdits.some((edit) => fuzzyFindText(normalizedContent, edit.oldText).usedFuzzyMatch);
|
|
6553
|
+
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
|
6554
|
+
const matchedEdits = normalizedEdits.map((edit, i) => {
|
|
6555
|
+
const match = fuzzyFindText(replacementBaseContent, edit.oldText);
|
|
6556
|
+
if (!match.found) throw new Error(notFound$1(path, i, normalizedEdits.length));
|
|
6557
|
+
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
|
|
6558
|
+
if (occurrences > 1) throw new Error(duplicate(path, i, normalizedEdits.length, occurrences));
|
|
6559
|
+
return {
|
|
6560
|
+
editIndex: i,
|
|
6561
|
+
matchIndex: match.index,
|
|
6562
|
+
matchLength: match.matchLength,
|
|
6563
|
+
newText: edit.newText
|
|
6564
|
+
};
|
|
6565
|
+
});
|
|
6566
|
+
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
6567
|
+
for (let i = 1; i < matchedEdits.length; i++) {
|
|
6568
|
+
const previous = matchedEdits[i - 1];
|
|
6569
|
+
const current = matchedEdits[i];
|
|
6570
|
+
if (previous.matchIndex + previous.matchLength > current.matchIndex) throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
|
|
6571
|
+
}
|
|
6572
|
+
const newContent = usedFuzzyMatch ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) : applyReplacements(replacementBaseContent, matchedEdits);
|
|
6573
|
+
if (normalizedContent === newContent) throw new Error(noChange(path, normalizedEdits.length));
|
|
6574
|
+
return {
|
|
6575
|
+
baseContent: normalizedContent,
|
|
6576
|
+
newContent
|
|
6577
|
+
};
|
|
6578
|
+
};
|
|
6579
|
+
/** A standard unified patch. */
|
|
6580
|
+
const generateUnifiedPatch = (path, oldContent, newContent, contextLines = 4) => Diff.createTwoFilesPatch(path, path, oldContent, newContent, void 0, void 0, {
|
|
6581
|
+
context: contextLines,
|
|
6582
|
+
headerOptions: Diff.FILE_HEADERS_ONLY
|
|
6583
|
+
});
|
|
6584
|
+
/**
|
|
6585
|
+
* A display diff with line numbers and `contextLines` of context around each
|
|
6586
|
+
* change, plus the first changed line in the new file.
|
|
6587
|
+
*/
|
|
6588
|
+
const generateDiffString = (oldContent, newContent, contextLines = 4) => {
|
|
6589
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
6590
|
+
const output = [];
|
|
6591
|
+
const lineNumWidth = String(Math.max(oldContent.split("\n").length, newContent.split("\n").length)).length;
|
|
6592
|
+
const numbered = (prefix, lineNum, line) => `${prefix}${String(lineNum).padStart(lineNumWidth, " ")} ${line}`;
|
|
6593
|
+
const ellipsis = ` ${"".padStart(lineNumWidth, " ")} ...`;
|
|
6594
|
+
let oldLineNum = 1;
|
|
6595
|
+
let newLineNum = 1;
|
|
6596
|
+
let lastWasChange = false;
|
|
6597
|
+
let firstChangedLine;
|
|
6598
|
+
parts.forEach((part, i) => {
|
|
6599
|
+
const raw = part.value.split("\n");
|
|
6600
|
+
if (raw[raw.length - 1] === "") raw.pop();
|
|
6601
|
+
if (part.added || part.removed) {
|
|
6602
|
+
firstChangedLine ??= newLineNum;
|
|
6603
|
+
for (const line of raw) if (part.added) output.push(numbered("+", newLineNum++, line));
|
|
6604
|
+
else output.push(numbered("-", oldLineNum++, line));
|
|
6605
|
+
lastWasChange = true;
|
|
6606
|
+
return;
|
|
6607
|
+
}
|
|
6608
|
+
const context = (lines) => {
|
|
6609
|
+
for (const line of lines) {
|
|
6610
|
+
output.push(numbered(" ", oldLineNum, line));
|
|
6611
|
+
oldLineNum++;
|
|
6612
|
+
newLineNum++;
|
|
6613
|
+
}
|
|
6614
|
+
};
|
|
6615
|
+
const skip = (count) => {
|
|
6616
|
+
output.push(ellipsis);
|
|
6617
|
+
oldLineNum += count;
|
|
6618
|
+
newLineNum += count;
|
|
6619
|
+
};
|
|
6620
|
+
const next = parts[i + 1];
|
|
6621
|
+
const hasTrailingChange = next !== void 0 && (next.added || next.removed);
|
|
6622
|
+
if (lastWasChange && hasTrailingChange) {
|
|
6623
|
+
if (raw.length <= contextLines * 2) context(raw);
|
|
6624
|
+
else {
|
|
6625
|
+
context(raw.slice(0, contextLines));
|
|
6626
|
+
skip(raw.length - contextLines * 2);
|
|
6627
|
+
context(raw.slice(raw.length - contextLines));
|
|
6628
|
+
}
|
|
6629
|
+
} else if (lastWasChange) {
|
|
6630
|
+
context(raw.slice(0, contextLines));
|
|
6631
|
+
if (raw.length > contextLines) skip(raw.length - contextLines);
|
|
6632
|
+
} else if (hasTrailingChange) {
|
|
6633
|
+
const skipped = Math.max(0, raw.length - contextLines);
|
|
6634
|
+
if (skipped > 0) skip(skipped);
|
|
6635
|
+
context(raw.slice(skipped));
|
|
6636
|
+
} else {
|
|
6637
|
+
oldLineNum += raw.length;
|
|
6638
|
+
newLineNum += raw.length;
|
|
6639
|
+
}
|
|
6640
|
+
lastWasChange = false;
|
|
6641
|
+
});
|
|
6642
|
+
return {
|
|
6643
|
+
diff: output.join("\n"),
|
|
6644
|
+
firstChangedLine
|
|
6645
|
+
};
|
|
6646
|
+
};
|
|
6647
|
+
//#endregion
|
|
6648
|
+
//#region src/tool/path.ts
|
|
6649
|
+
/**
|
|
6650
|
+
* How file tools read the path a model wrote. Models copy paths out of prose and
|
|
6651
|
+
* terminals, so a path arrives with Unicode spaces or an `@` mention prefix the
|
|
6652
|
+
* file never had. Relative paths stay relative: the mount resolves them.
|
|
6653
|
+
*/
|
|
6654
|
+
const UNICODE_SPACES = /[ - ]/g;
|
|
6655
|
+
const NARROW_NO_BREAK_SPACE = " ";
|
|
6656
|
+
/** The path a file tool acts on. */
|
|
6657
|
+
const normalize$1 = (path) => {
|
|
6658
|
+
const normalized = path.replace(UNICODE_SPACES, " ");
|
|
6659
|
+
return normalized.startsWith("@") ? normalized.slice(1) : normalized;
|
|
6660
|
+
};
|
|
6661
|
+
/**
|
|
6662
|
+
* The path to read: {@link normalize}d, or the first existing spelling the model
|
|
6663
|
+
* could not have typed — macOS screenshot names use a narrow no-break space
|
|
6664
|
+
* before AM/PM, decomposed (NFD) accents, and curly apostrophes. Falls back to
|
|
6665
|
+
* the normalized path, so a missing file is reported under the name asked for.
|
|
6666
|
+
*/
|
|
6667
|
+
const resolveRead = (fs, path) => Effect.gen(function* () {
|
|
6668
|
+
const normalized = normalize$1(path);
|
|
6669
|
+
const variants = /* @__PURE__ */ new Set([
|
|
6670
|
+
normalized,
|
|
6671
|
+
normalized.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
|
|
6672
|
+
normalized.normalize("NFD"),
|
|
6673
|
+
normalized.replace(/'/g, "’"),
|
|
6674
|
+
normalized.normalize("NFD").replace(/'/g, "’")
|
|
6675
|
+
]);
|
|
6676
|
+
for (const variant of variants) if (yield* fs.exists(variant)) return variant;
|
|
6677
|
+
return normalized;
|
|
6678
|
+
});
|
|
6679
|
+
//#endregion
|
|
6680
|
+
//#region src/plugin/builtin/tool/edit.ts
|
|
6681
|
+
/**
|
|
6682
|
+
* The built-in edit plugin: exact-text replacements, all matched against the
|
|
6683
|
+
* original file, under the file's mutation lock for the whole read → match →
|
|
6684
|
+
* write. The model reads a one-line summary; `details` carries the diff.
|
|
6685
|
+
*/
|
|
6686
|
+
const ReplaceEdit = Schema.Struct({
|
|
6687
|
+
oldText: Schema.String.annotate({ description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call." }),
|
|
6688
|
+
newText: Schema.String.annotate({ description: "Replacement text for this targeted edit." })
|
|
6689
|
+
});
|
|
6690
|
+
const EditParams = Schema.Struct({
|
|
6691
|
+
path: Schema.String.annotate({ description: "Path to the file to edit (relative or absolute)" }),
|
|
6692
|
+
edits: Schema.Array(ReplaceEdit).annotate({ description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead." })
|
|
6693
|
+
});
|
|
6694
|
+
const EditSuccess = Schema.Struct({
|
|
6695
|
+
path: Schema.String,
|
|
6696
|
+
replaced: Schema.Finite,
|
|
6697
|
+
diff: Schema.String,
|
|
6698
|
+
patch: Schema.String,
|
|
6699
|
+
firstChangedLine: Schema.optional(Schema.Finite)
|
|
6700
|
+
});
|
|
6701
|
+
var EditFailed = class extends Schema.TaggedError()("EditFailed", { message: Schema.String }) {};
|
|
6702
|
+
const isSingleEdit = (value) => {
|
|
6703
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6704
|
+
const edit = value;
|
|
6705
|
+
return typeof edit.oldText === "string" && typeof edit.newText === "string";
|
|
6706
|
+
};
|
|
6707
|
+
/**
|
|
6708
|
+
* Repair shapes models commonly send: `edits` as a JSON string or as a single
|
|
6709
|
+
* edit object, and a top-level `oldText`/`newText` pair. Works on a copy; the
|
|
6710
|
+
* call's arguments stay unchanged.
|
|
6711
|
+
*/
|
|
6712
|
+
const prepareEditArguments = (input) => {
|
|
6713
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
|
6714
|
+
const args = { ...input };
|
|
6715
|
+
if (typeof args.edits === "string") try {
|
|
6716
|
+
const parsed = JSON.parse(args.edits);
|
|
6717
|
+
if (Array.isArray(parsed)) args.edits = parsed;
|
|
6718
|
+
else if (isSingleEdit(parsed)) args.edits = [parsed];
|
|
6719
|
+
} catch {}
|
|
6720
|
+
else if (isSingleEdit(args.edits)) args.edits = [args.edits];
|
|
6721
|
+
if (typeof args.oldText !== "string" || typeof args.newText !== "string") return args;
|
|
6722
|
+
const edits = Array.isArray(args.edits) ? [...args.edits] : [];
|
|
6723
|
+
edits.push({
|
|
6724
|
+
oldText: args.oldText,
|
|
6725
|
+
newText: args.newText
|
|
6726
|
+
});
|
|
6727
|
+
const { oldText: _oldText, newText: _newText, ...rest } = args;
|
|
6728
|
+
return {
|
|
6729
|
+
...rest,
|
|
6730
|
+
edits
|
|
6731
|
+
};
|
|
6732
|
+
};
|
|
6733
|
+
const editDef = tool_exports.define({
|
|
6734
|
+
name: "edit",
|
|
6735
|
+
label: "edit",
|
|
6736
|
+
description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
|
|
6737
|
+
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
|
6738
|
+
promptGuidelines: [
|
|
6739
|
+
"Use edit for precise changes (edits[].oldText must match exactly)",
|
|
6740
|
+
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
|
|
6741
|
+
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
|
|
6742
|
+
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions."
|
|
6743
|
+
],
|
|
6744
|
+
prepareArguments: prepareEditArguments,
|
|
6745
|
+
parameters: EditParams,
|
|
6746
|
+
constrainedSampling: {
|
|
6747
|
+
type: "json_schema",
|
|
6748
|
+
strict: "prefer"
|
|
6749
|
+
},
|
|
6750
|
+
success: EditSuccess,
|
|
6751
|
+
failure: EditFailed,
|
|
6752
|
+
encodeContent: (success) => [{
|
|
6753
|
+
type: "text",
|
|
6754
|
+
text: `Successfully replaced ${success.replaced} block(s) in ${success.path}.`
|
|
6755
|
+
}],
|
|
6756
|
+
encodeFailureContent: (failure) => [{
|
|
6757
|
+
type: "text",
|
|
6758
|
+
text: failure.message
|
|
6759
|
+
}],
|
|
6760
|
+
encodeDetails: ({ diff, patch, firstChangedLine }) => ({
|
|
6761
|
+
diff,
|
|
6762
|
+
patch,
|
|
6763
|
+
...firstChangedLine === void 0 ? {} : { firstChangedLine }
|
|
6764
|
+
})
|
|
6765
|
+
});
|
|
6766
|
+
const fail$4 = (message) => Effect.fail(new EditFailed({ message }));
|
|
6767
|
+
/** Filesystem failures name the backend's error code, as Pi's do. */
|
|
6768
|
+
const accessFailure = (path) => (error) => {
|
|
6769
|
+
const code = error.cause?.code;
|
|
6770
|
+
return fail$4(`Could not edit file: ${path}. Error code: ${typeof code === "string" ? code : "unknown"}.`);
|
|
6771
|
+
};
|
|
6772
|
+
const editHandler = (params) => Effect.gen(function* () {
|
|
6773
|
+
const { path, edits } = params;
|
|
6774
|
+
if (edits.length === 0) return yield* fail$4("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
6775
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
6776
|
+
const mutation = yield* io_exports.SandboxIO.Mutation;
|
|
6777
|
+
const resolved = normalize$1(path);
|
|
6778
|
+
return yield* mutation.withFile(resolved, Effect.gen(function* () {
|
|
6779
|
+
if (!(yield* fs.stat(resolved)).isFile) return yield* fail$4(`Could not edit file: ${path}. Path is not a file.`);
|
|
6780
|
+
const { bom, text: content } = stripBom(yield* fs.readFile(resolved));
|
|
6781
|
+
const originalEnding = detectLineEnding(content);
|
|
6782
|
+
const { baseContent, newContent } = yield* Effect.try({
|
|
6783
|
+
try: () => applyEditsToNormalizedContent(normalizeToLF(content), edits, path),
|
|
6784
|
+
catch: (cause) => new EditFailed({ message: cause instanceof Error ? cause.message : String(cause) })
|
|
6785
|
+
});
|
|
6786
|
+
yield* fs.writeFile(resolved, bom + restoreLineEndings(newContent, originalEnding));
|
|
6787
|
+
const { diff, firstChangedLine } = generateDiffString(baseContent, newContent);
|
|
6788
|
+
return {
|
|
6789
|
+
path,
|
|
6790
|
+
replaced: edits.length,
|
|
6791
|
+
diff,
|
|
6792
|
+
patch: generateUnifiedPatch(path, baseContent, newContent),
|
|
6793
|
+
...firstChangedLine === void 0 ? {} : { firstChangedLine }
|
|
6794
|
+
};
|
|
6795
|
+
}));
|
|
6796
|
+
}).pipe(Effect.catchTag("SandboxFileSystemError", accessFailure(params.path)));
|
|
6797
|
+
/** The edit tool: definition + handler, wired the testable (def/exec split) way. */
|
|
6798
|
+
const editTool = tool_exports.implement(editDef, editHandler);
|
|
6799
|
+
const editPlugin = (0, plugin_exports$1.define)({
|
|
6800
|
+
id: "codework.tool.edit",
|
|
6801
|
+
kind: "tool",
|
|
6802
|
+
setup: Effect.fn("EditPlugin.setup")(function* (ctx) {
|
|
6803
|
+
const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Mutation, yield* io_exports.SandboxIO.Mutation));
|
|
6804
|
+
ctx.plugin.tools.add(tool_exports.provide(editTool, mounted));
|
|
6805
|
+
})
|
|
6806
|
+
});
|
|
6807
|
+
//#endregion
|
|
6808
|
+
//#region src/tool/binary.ts
|
|
6809
|
+
/** `<home>/bin` on the host, where binaries for `local` mounts are installed. */
|
|
6810
|
+
var HostBin = class extends Context.Service()("@codeworksh/harness/tool/binary/HostBin") {};
|
|
6811
|
+
const specs = {
|
|
6812
|
+
rg: {
|
|
6813
|
+
label: "ripgrep (rg)",
|
|
6814
|
+
repo: "BurntSushi/ripgrep",
|
|
6815
|
+
tagPrefix: "",
|
|
6816
|
+
commands: ["rg"],
|
|
6817
|
+
asset: (version, os, arch) => `ripgrep-${version}-${arch}-${os === "darwin" ? "apple-darwin" : "unknown-linux-musl"}.tar.gz`
|
|
6818
|
+
},
|
|
6819
|
+
fd: {
|
|
6820
|
+
label: "fd",
|
|
6821
|
+
repo: "sharkdp/fd",
|
|
6822
|
+
tagPrefix: "v",
|
|
6823
|
+
commands: ["fd", "fdfind"],
|
|
6824
|
+
asset: (version, os, arch) => `fd-v${version}-${arch}-${os === "darwin" ? "apple-darwin" : "unknown-linux-musl"}.tar.gz`
|
|
6825
|
+
}
|
|
6826
|
+
};
|
|
6827
|
+
const NETWORK_TIMEOUT = Duration.seconds(10);
|
|
6828
|
+
const DOWNLOAD_TIMEOUT = Duration.seconds(120);
|
|
6829
|
+
/** Why a missing binary could not be installed; the model reads the message. */
|
|
6830
|
+
var InstallError$1 = class extends Schema.TaggedError()("InstallError", { message: Schema.String }) {};
|
|
6831
|
+
const installError = (message) => new InstallError$1({ message });
|
|
6832
|
+
const available = /* @__PURE__ */ new Map();
|
|
6833
|
+
const keyOf = (sandbox, name) => `${sandbox.id}\0${name}`;
|
|
6834
|
+
const run$2 = (shell, command) => shell.exec(command).pipe(Effect.orElseSucceed(() => ({
|
|
6835
|
+
stdout: "",
|
|
6836
|
+
stderr: "",
|
|
6837
|
+
exitCode: 127
|
|
6838
|
+
})));
|
|
6839
|
+
const works = (shell, command) => Effect.map(run$2(shell, `${(0, shell_exports.quote)(command)} --version`), (result) => result.exitCode === 0);
|
|
6840
|
+
/** A GET whose `read` of the response, body included, finishes within `timeout`. */
|
|
6841
|
+
const get$2 = (url, timeout, read, init = {}) => HttpClient.get(url).pipe(Effect.flatMap(read), Effect.timeout(timeout), Effect.mapError((cause) => Schema.is(InstallError$1)(cause) ? cause : installError(`${url}: ${cause instanceof Error ? cause.message : String(cause)}`)), Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.RequestInit, init));
|
|
6842
|
+
/** The latest release, read from the redirect of the release page; the API endpoint is rate limited. */
|
|
6843
|
+
const latestVersion = (repo) => Effect.gen(function* () {
|
|
6844
|
+
const response = yield* get$2(`https://github.com/${repo}/releases/latest`, NETWORK_TIMEOUT, Effect.succeed, { redirect: "manual" });
|
|
6845
|
+
const location = response.headers["location"] ?? "";
|
|
6846
|
+
const tag = new URL(location, "https://github.com").pathname.split("/").pop();
|
|
6847
|
+
if (!location.includes("/releases/tag/") || !tag) return yield* installError(`Failed to resolve latest ${repo} release: HTTP ${response.status}`);
|
|
6848
|
+
return decodeURIComponent(tag).replace(/^v/, "");
|
|
6849
|
+
});
|
|
6850
|
+
const platformOf = (shell) => Effect.gen(function* () {
|
|
6851
|
+
const [os, arch] = (yield* run$2(shell, "uname -sm")).stdout.trim().toLowerCase().split(/\s+/);
|
|
6852
|
+
const mappedOs = os === "darwin" || os === "linux" ? os : void 0;
|
|
6853
|
+
const mappedArch = arch === "x86_64" || arch === "amd64" ? "x86_64" : arch === "aarch64" || arch === "arm64" ? "aarch64" : void 0;
|
|
6854
|
+
if (mappedOs === void 0 || mappedArch === void 0) return yield* installError(`Unsupported platform: ${os ?? "unknown"}/${arch ?? "unknown"}`);
|
|
6855
|
+
return {
|
|
6856
|
+
os: mappedOs,
|
|
6857
|
+
arch: mappedArch
|
|
6858
|
+
};
|
|
6859
|
+
});
|
|
6860
|
+
/** Unpacks with the sandbox's own `tar`, as Pi does on its host; the archive layout varies by release. */
|
|
6861
|
+
const unpack = [
|
|
6862
|
+
"set -e",
|
|
6863
|
+
"dir=$1; archive=$2; name=$3; tmp=\"$dir/.extract-$name-$$\"",
|
|
6864
|
+
"trap 'rm -rf \"$tmp\" \"$archive\"' EXIT",
|
|
6865
|
+
"mkdir -p \"$tmp\"",
|
|
6866
|
+
"tar -xzf \"$archive\" -C \"$tmp\"",
|
|
6867
|
+
"found=$(find \"$tmp\" -type f -name \"$name\" | head -n 1)",
|
|
6868
|
+
"[ -n \"$found\" ] || { echo \"binary $name not found in archive\" >&2; exit 1; }",
|
|
6869
|
+
"mv \"$found\" \"$dir/$name\"",
|
|
6870
|
+
"chmod 755 \"$dir/$name\""
|
|
6871
|
+
].join("\n");
|
|
6872
|
+
const install$1 = (name, shell, fs, dir) => Effect.gen(function* () {
|
|
6873
|
+
const spec = specs[name];
|
|
6874
|
+
const { os, arch } = yield* platformOf(shell);
|
|
6875
|
+
const version = name === "fd" && os === "darwin" && arch === "x86_64" ? "10.3.0" : yield* latestVersion(spec.repo);
|
|
6876
|
+
const asset = spec.asset(version, os, arch);
|
|
6877
|
+
const url = `https://github.com/${spec.repo}/releases/download/${spec.tagPrefix}${version}/${asset}`;
|
|
6878
|
+
const bytes = new Uint8Array(yield* get$2(url, DOWNLOAD_TIMEOUT, (response) => response.status === 200 ? response.arrayBuffer : Effect.fail(installError(`Download failed with HTTP ${response.status}: ${url}`))));
|
|
6879
|
+
const archive = posix.join(dir, `.${asset}`);
|
|
6880
|
+
yield* fs.writeFile(archive, bytes).pipe(Effect.mapError((error) => installError(`Cannot write ${archive}: ${error.cause instanceof Error ? error.cause.message : error.message}`)));
|
|
6881
|
+
const result = yield* run$2(shell, `sh -c ${(0, shell_exports.quote)(unpack)} _ ${(0, shell_exports.quoteArgv)([
|
|
6882
|
+
dir,
|
|
6883
|
+
archive,
|
|
6884
|
+
name
|
|
6885
|
+
])}`);
|
|
6886
|
+
if (result.exitCode !== 0) return yield* installError(`Failed to unpack ${asset}: ${(result.stderr || result.stdout).trim()}`);
|
|
6887
|
+
return posix.join(dir, name);
|
|
6888
|
+
});
|
|
6889
|
+
/** Where `kind` keeps installed binaries. */
|
|
6890
|
+
const binDir = (sandbox, shell) => Effect.gen(function* () {
|
|
6891
|
+
if (sandbox.kind === "local") return yield* HostBin;
|
|
6892
|
+
const home = (yield* run$2(shell, "printf %s \"$HOME\"")).stdout.trim();
|
|
6893
|
+
return posix.join(home || "/tmp", ".codework", "bin");
|
|
6894
|
+
});
|
|
6895
|
+
const resolve$3 = (name, sandbox, shell, fs) => Effect.gen(function* () {
|
|
6896
|
+
const dir = yield* binDir(sandbox, shell);
|
|
6897
|
+
for (const command of [posix.join(dir, name), ...specs[name].commands]) if (yield* works(shell, command)) return {
|
|
6898
|
+
_tag: "Available",
|
|
6899
|
+
command
|
|
6900
|
+
};
|
|
6901
|
+
const installed = yield* install$1(name, shell, fs, dir).pipe(Effect.result);
|
|
6902
|
+
if (installed._tag === "Success" && (yield* works(shell, installed.success))) return {
|
|
6903
|
+
_tag: "Available",
|
|
6904
|
+
command: installed.success
|
|
6905
|
+
};
|
|
6906
|
+
const reason = installed._tag === "Failure" ? `: ${installed.failure.message}` : "";
|
|
6907
|
+
return {
|
|
6908
|
+
_tag: "Unavailable",
|
|
6909
|
+
message: `${specs[name].label} is not available in this sandbox and could not be installed${reason}`
|
|
6910
|
+
};
|
|
6911
|
+
});
|
|
6912
|
+
const virtual = (name) => name === "rg" ? {
|
|
6913
|
+
_tag: "Available",
|
|
6914
|
+
command: "rg"
|
|
6915
|
+
} : {
|
|
6916
|
+
_tag: "Unavailable",
|
|
6917
|
+
message: `${specs[name].label} is not available in this sandbox`
|
|
6918
|
+
};
|
|
6919
|
+
/**
|
|
6920
|
+
* The shared probe for `name` in this sandbox, started when none is remembered.
|
|
6921
|
+
* It runs detached, so cancelling the call that started it cannot cancel it for
|
|
6922
|
+
* the others waiting on it; an unavailable result is not remembered, so the next
|
|
6923
|
+
* call tries again.
|
|
6924
|
+
*/
|
|
6925
|
+
const probe$1 = (name, sandbox, shell, fs) => Effect.gen(function* () {
|
|
6926
|
+
const key = keyOf(sandbox, name);
|
|
6927
|
+
const existing = available.get(key);
|
|
6928
|
+
if (existing !== void 0) return existing;
|
|
6929
|
+
const deferred = Deferred.makeUnsafe();
|
|
6930
|
+
available.set(key, deferred);
|
|
6931
|
+
yield* resolve$3(name, sandbox, shell, fs).pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
6932
|
+
if ((exit._tag === "Failure" || exit.value._tag === "Unavailable") && available.get(key) === deferred) available.delete(key);
|
|
6933
|
+
})), Effect.onExit((exit) => Deferred.done(deferred, exit)), Effect.forkDetach);
|
|
6934
|
+
return deferred;
|
|
6935
|
+
});
|
|
6936
|
+
/**
|
|
6937
|
+
* Run `use` with the command for `name`. When the run reports the binary gone
|
|
6938
|
+
* (`exitCode` 127, e.g. a sandbox reset to a fresh image), the remembered probe is
|
|
6939
|
+
* dropped and the run is tried once more with a fresh one. Only the probe that
|
|
6940
|
+
* gave the stale command is dropped, so concurrent retries share one new probe.
|
|
6941
|
+
*/
|
|
6942
|
+
const withCommand = (name, sandbox, shell, fs, use) => Effect.gen(function* () {
|
|
6943
|
+
if (sandbox.kind === "virtual") {
|
|
6944
|
+
const resolved = virtual(name);
|
|
6945
|
+
return resolved._tag === "Unavailable" ? resolved : yield* use(resolved.command);
|
|
6946
|
+
}
|
|
6947
|
+
for (let attempt = 0;; attempt++) {
|
|
6948
|
+
const deferred = yield* probe$1(name, sandbox, shell, fs);
|
|
6949
|
+
const resolved = yield* Deferred.await(deferred);
|
|
6950
|
+
if (resolved._tag === "Unavailable") return resolved;
|
|
6951
|
+
const result = yield* use(resolved.command);
|
|
6952
|
+
if (result.exitCode !== 127 || attempt > 0) return result;
|
|
6953
|
+
const key = keyOf(sandbox, name);
|
|
6954
|
+
if (available.get(key) === deferred) available.delete(key);
|
|
6955
|
+
}
|
|
6956
|
+
});
|
|
6957
|
+
//#endregion
|
|
6958
|
+
//#region src/plugin/builtin/tool/find.ts
|
|
6959
|
+
/**
|
|
6960
|
+
* The built-in find plugin: fd in the mounted sandbox. A virtual mount has no fd,
|
|
6961
|
+
* and the tool says so rather than emulating it.
|
|
6962
|
+
*/
|
|
6963
|
+
const FindParams = Schema.Struct({
|
|
6964
|
+
pattern: Schema.String.annotate({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'" }),
|
|
6965
|
+
path: Schema.optional(Schema.String.annotate({ description: "Directory to search in (default: current directory)" })),
|
|
6966
|
+
limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of results (default: 1000)" }))
|
|
6967
|
+
});
|
|
6968
|
+
const FindDetails = Schema.Struct({
|
|
6969
|
+
truncation: Schema.optional(Schema.Unknown),
|
|
6970
|
+
resultLimitReached: Schema.optional(Schema.Finite)
|
|
6971
|
+
});
|
|
6972
|
+
const FindSuccess = Schema.Struct({
|
|
6973
|
+
text: Schema.String,
|
|
6974
|
+
details: Schema.optional(FindDetails)
|
|
6975
|
+
});
|
|
6976
|
+
var FindFailed = class extends Schema.TaggedError()("FindFailed", { message: Schema.String }) {};
|
|
6977
|
+
const DEFAULT_LIMIT$2 = 1e3;
|
|
6978
|
+
const FD_ERROR = "[fd error]:";
|
|
6979
|
+
const findDef = tool_exports.define({
|
|
6980
|
+
name: "find",
|
|
6981
|
+
label: "find",
|
|
6982
|
+
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT$2} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
|
|
6983
|
+
promptSnippet: "Find files by glob pattern (respects .gitignore)",
|
|
6984
|
+
parameters: FindParams,
|
|
6985
|
+
constrainedSampling: {
|
|
6986
|
+
type: "json_schema",
|
|
6987
|
+
strict: "prefer"
|
|
6988
|
+
},
|
|
6989
|
+
success: FindSuccess,
|
|
6990
|
+
failure: FindFailed,
|
|
6991
|
+
encodeContent: (success) => [{
|
|
6992
|
+
type: "text",
|
|
6993
|
+
text: success.text
|
|
6994
|
+
}],
|
|
6995
|
+
encodeFailureContent: (failure) => [{
|
|
6996
|
+
type: "text",
|
|
6997
|
+
text: failure.message
|
|
6998
|
+
}],
|
|
6999
|
+
encodeDetails: (success) => success.details
|
|
7000
|
+
});
|
|
7001
|
+
const fail$3 = (message) => Effect.fail(new FindFailed({ message }));
|
|
7002
|
+
/** Relative to the search root, keeping a directory's trailing slash. */
|
|
7003
|
+
const relativize = (result, searchPath) => {
|
|
7004
|
+
const relative = posix.isAbsolute(result) ? posix.relative(searchPath, result) : result;
|
|
7005
|
+
return result.endsWith("/") && !relative.endsWith("/") ? `${relative}/` : relative;
|
|
7006
|
+
};
|
|
7007
|
+
const insideGitRepo = (fs, searchPath) => Effect.gen(function* () {
|
|
7008
|
+
for (let current = searchPath;; current = posix.dirname(current)) {
|
|
7009
|
+
if (yield* fs.exists(posix.join(current, ".git")).pipe(Effect.orElseSucceed(() => false))) return true;
|
|
7010
|
+
if (posix.dirname(current) === current) return false;
|
|
7011
|
+
}
|
|
7012
|
+
});
|
|
7013
|
+
const findHandler = (params) => Effect.gen(function* () {
|
|
7014
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7015
|
+
const shell = yield* io_exports.SandboxIO.Shell;
|
|
7016
|
+
const sandbox = yield* io_exports.SandboxIO.Current;
|
|
7017
|
+
const searchPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
|
|
7018
|
+
const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT$2));
|
|
7019
|
+
if (!(yield* fs.exists(searchPath).pipe(Effect.orElseSucceed(() => false)))) return yield* fail$3(`Path not found: ${searchPath}`);
|
|
7020
|
+
const argv = [
|
|
7021
|
+
"--glob",
|
|
7022
|
+
"--color=never",
|
|
7023
|
+
"--hidden",
|
|
7024
|
+
...(yield* insideGitRepo(fs, searchPath)) ? [] : ["--no-require-git"]
|
|
7025
|
+
];
|
|
7026
|
+
argv.push("--max-results", String(limit));
|
|
7027
|
+
let pattern = params.pattern;
|
|
7028
|
+
if (pattern.includes("/")) {
|
|
7029
|
+
argv.push("--full-path");
|
|
7030
|
+
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`;
|
|
7031
|
+
}
|
|
7032
|
+
argv.push("--", pattern, searchPath);
|
|
7033
|
+
const run = yield* withCommand("fd", sandbox, shell, fs, (command) => shell.exec((0, shell_exports.quoteArgv)([command, ...argv])));
|
|
7034
|
+
if ("_tag" in run) return yield* fail$3(run.message);
|
|
7035
|
+
const output = run.stdout.split("\n").map((line) => line.replace(/\r$/, "").trim()).filter((line) => line.length > 0);
|
|
7036
|
+
const lines = output.filter((line) => !line.startsWith(FD_ERROR));
|
|
7037
|
+
const errors = [run.stderr.trim(), ...output.filter((line) => line.startsWith(FD_ERROR))].filter((text) => text.length > 0).join("\n");
|
|
7038
|
+
if (run.exitCode !== 0 && lines.length === 0) return yield* fail$3(errors || `fd exited with code ${run.exitCode}`);
|
|
7039
|
+
if (lines.length === 0) return { text: "No files found matching pattern" };
|
|
7040
|
+
const results = lines.map((line) => relativize(line, searchPath)).sort();
|
|
7041
|
+
const resultLimitReached = results.length >= limit;
|
|
7042
|
+
const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
|
|
7043
|
+
const notices = [];
|
|
7044
|
+
const details = {};
|
|
7045
|
+
if (resultLimitReached) {
|
|
7046
|
+
notices.push(`${limit} results limit reached. Use limit=${limit * 2} for more, or refine pattern`);
|
|
7047
|
+
details.resultLimitReached = limit;
|
|
7048
|
+
}
|
|
7049
|
+
if (truncation.truncated) {
|
|
7050
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
7051
|
+
details.truncation = truncation;
|
|
7052
|
+
}
|
|
7053
|
+
const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
|
|
7054
|
+
return Object.keys(details).length > 0 ? {
|
|
7055
|
+
text,
|
|
7056
|
+
details
|
|
7057
|
+
} : { text };
|
|
7058
|
+
}).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error)));
|
|
7059
|
+
/** The find tool: definition + handler, wired the testable (def/exec split) way. */
|
|
7060
|
+
const findTool = tool_exports.implement(findDef, findHandler);
|
|
7061
|
+
const findPlugin = (0, plugin_exports$1.define)({
|
|
7062
|
+
id: "codework.tool.find",
|
|
7063
|
+
kind: "tool",
|
|
7064
|
+
optIn: true,
|
|
7065
|
+
setup: Effect.fn("FindPlugin.setup")(function* (ctx) {
|
|
7066
|
+
const mounted = Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Shell, yield* io_exports.SandboxIO.Shell), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current), Layer.succeed(HostBin, ctx.paths.bin));
|
|
7067
|
+
ctx.plugin.tools.add(tool_exports.provide(findTool, mounted));
|
|
7068
|
+
})
|
|
7069
|
+
});
|
|
7070
|
+
//#endregion
|
|
7071
|
+
//#region src/plugin/builtin/tool/grep.ts
|
|
7072
|
+
/**
|
|
7073
|
+
* The built-in grep plugin: ripgrep in the mounted sandbox — the host's or the
|
|
7074
|
+
* remote's own binary, or just-bash's built-in `rg` on a virtual mount. Only the
|
|
7075
|
+
* first `limit` match events leave the sandbox; context lines are read through
|
|
7076
|
+
* `scanLines`, so only their window crosses.
|
|
7077
|
+
*/
|
|
7078
|
+
const GrepParams = Schema.Struct({
|
|
7079
|
+
pattern: Schema.String.annotate({ description: "Search pattern (regex or literal string)" }),
|
|
7080
|
+
path: Schema.optional(Schema.String.annotate({ description: "Directory or file to search (default: current directory)" })),
|
|
7081
|
+
glob: Schema.optional(Schema.String.annotate({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
|
7082
|
+
ignoreCase: Schema.optional(Schema.Boolean.annotate({ description: "Case-insensitive search (default: false)" })),
|
|
7083
|
+
literal: Schema.optional(Schema.Boolean.annotate({ description: "Treat pattern as literal string instead of regex (default: false)" })),
|
|
7084
|
+
context: Schema.optional(Schema.Finite.annotate({ description: "Number of lines to show before and after each match (default: 0)" })),
|
|
7085
|
+
limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of matches to return (default: 100)" }))
|
|
7086
|
+
});
|
|
7087
|
+
const GrepDetails = Schema.Struct({
|
|
7088
|
+
truncation: Schema.optional(Schema.Unknown),
|
|
7089
|
+
matchLimitReached: Schema.optional(Schema.Finite),
|
|
7090
|
+
linesTruncated: Schema.optional(Schema.Boolean)
|
|
7091
|
+
});
|
|
7092
|
+
const GrepSuccess = Schema.Struct({
|
|
7093
|
+
text: Schema.String,
|
|
7094
|
+
details: Schema.optional(GrepDetails)
|
|
7095
|
+
});
|
|
7096
|
+
var GrepFailed = class extends Schema.TaggedError()("GrepFailed", { message: Schema.String }) {};
|
|
7097
|
+
const DEFAULT_LIMIT$1 = 100;
|
|
7098
|
+
const grepDef = tool_exports.define({
|
|
7099
|
+
name: "grep",
|
|
7100
|
+
label: "grep",
|
|
7101
|
+
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT$1} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to 500 chars.`,
|
|
7102
|
+
promptSnippet: "Search file contents for patterns (respects .gitignore)",
|
|
7103
|
+
parameters: GrepParams,
|
|
7104
|
+
constrainedSampling: {
|
|
7105
|
+
type: "json_schema",
|
|
7106
|
+
strict: "prefer"
|
|
7107
|
+
},
|
|
7108
|
+
success: GrepSuccess,
|
|
7109
|
+
failure: GrepFailed,
|
|
7110
|
+
encodeContent: (success) => [{
|
|
7111
|
+
type: "text",
|
|
7112
|
+
text: success.text
|
|
7113
|
+
}],
|
|
7114
|
+
encodeFailureContent: (failure) => [{
|
|
7115
|
+
type: "text",
|
|
7116
|
+
text: failure.message
|
|
7117
|
+
}],
|
|
7118
|
+
encodeDetails: (success) => success.details
|
|
7119
|
+
});
|
|
7120
|
+
const fail$2 = (message) => Effect.fail(new GrepFailed({ message }));
|
|
7121
|
+
/** One `--json` match event; anything else (begin/end/summary, a non-UTF-8 path) is skipped. */
|
|
7122
|
+
const parseMatch = (line) => {
|
|
7123
|
+
try {
|
|
7124
|
+
const event = JSON.parse(line);
|
|
7125
|
+
const path = event.data?.path?.text;
|
|
7126
|
+
const number = event.data?.line_number;
|
|
7127
|
+
const text = event.data?.lines?.text;
|
|
7128
|
+
if (event.type !== "match" || typeof path !== "string" || typeof number !== "number") return void 0;
|
|
7129
|
+
return {
|
|
7130
|
+
path,
|
|
7131
|
+
line: number,
|
|
7132
|
+
text: typeof text === "string" ? text : ""
|
|
7133
|
+
};
|
|
7134
|
+
} catch {
|
|
7135
|
+
return;
|
|
7136
|
+
}
|
|
7137
|
+
};
|
|
7138
|
+
/**
|
|
7139
|
+
* The search runs as one pipeline so a broad pattern never ships its whole result:
|
|
7140
|
+
* only match events pass, and `head` stops ripgrep at the limit. Some backends
|
|
7141
|
+
* merge stderr into stdout, so nothing relies on the two staying apart: ripgrep's
|
|
7142
|
+
* status rides the pipe as a last event, and its stderr waits in `errors` until the
|
|
7143
|
+
* pipeline is done, where it can no longer land inside a match.
|
|
7144
|
+
*/
|
|
7145
|
+
const pipeline = (argv, limit, errors) => [
|
|
7146
|
+
`mkdir -p ${(0, shell_exports.quote)(posix.dirname(errors))} 2>/dev/null`,
|
|
7147
|
+
`{ : > ${(0, shell_exports.quote)(errors)}; } 2>/dev/null || { echo ${(0, shell_exports.quote)(`cannot write ${errors}`)} >&2; exit 2; }`,
|
|
7148
|
+
`{ ${(0, shell_exports.quoteArgv)(argv)} 2>${(0, shell_exports.quote)(errors)}; echo '{"type":"rc","code":'"$?"'}'; } | grep -E '^\\{"type":"(match|rc)"' | head -n ${limit}`,
|
|
7149
|
+
`cat ${(0, shell_exports.quote)(errors)} >&2 2>/dev/null`,
|
|
7150
|
+
`rm -f ${(0, shell_exports.quote)(errors)}`
|
|
7151
|
+
].join("; ");
|
|
7152
|
+
const STATUS = /^\{"type":"rc","code":(\d+)\}$/;
|
|
7153
|
+
/** The pipeline's output: match events, ripgrep's status, and anything else, which is its stderr. */
|
|
7154
|
+
const parse$2 = (stdout, stderr) => {
|
|
7155
|
+
const matches = [];
|
|
7156
|
+
const errors = [];
|
|
7157
|
+
let status;
|
|
7158
|
+
for (const line of stdout.split("\n")) {
|
|
7159
|
+
const rc = STATUS.exec(line);
|
|
7160
|
+
if (rc !== null) status ??= Number(rc[1]);
|
|
7161
|
+
else if (line.startsWith("{\"type\":\"match\"")) {
|
|
7162
|
+
const match = parseMatch(line);
|
|
7163
|
+
if (match !== void 0) matches.push(match);
|
|
7164
|
+
} else if (line.trim().length > 0) errors.push(line);
|
|
7165
|
+
}
|
|
7166
|
+
return {
|
|
7167
|
+
matches,
|
|
7168
|
+
status,
|
|
7169
|
+
errors: [stderr.trim(), ...errors].filter((text) => text.length > 0).join("\n")
|
|
7170
|
+
};
|
|
7171
|
+
};
|
|
7172
|
+
/** A line as shown: no line ending, cut to {@link GREP_MAX_LINE_LENGTH}. */
|
|
7173
|
+
const shown = (line) => truncateLine(line.replace(/\r\n/g, "\n").replace(/\r/g, "").replace(/\n$/, ""));
|
|
7174
|
+
/**
|
|
7175
|
+
* The match with its context lines. The window is read in one bounded scan; a
|
|
7176
|
+
* line it could not fit is read on its own, and a line too long even for that is
|
|
7177
|
+
* marked rather than shown blank. The match line itself is ripgrep's.
|
|
7178
|
+
*/
|
|
7179
|
+
const contextBlock = (fs, match, context, label) => Effect.gen(function* () {
|
|
7180
|
+
const startLine = Math.max(0, match.line - 1 - context);
|
|
7181
|
+
const scan = yield* fs.scanLines(match.path, {
|
|
7182
|
+
startLine,
|
|
7183
|
+
endLine: match.line + context,
|
|
7184
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
7185
|
+
}).pipe(Effect.option);
|
|
7186
|
+
if (scan._tag === "None" || scan.value.totalLines === 0) return {
|
|
7187
|
+
lines: [`${label}:${match.line}: (unable to read file)`],
|
|
7188
|
+
truncated: false
|
|
7189
|
+
};
|
|
7190
|
+
const window = scan.value.lines === 0 ? [] : scan.value.text.split("\n");
|
|
7191
|
+
const lineAt = (index) => index - startLine < window.length ? Effect.succeed(window[index - startLine]) : fs.scanLines(match.path, {
|
|
7192
|
+
startLine: index,
|
|
7193
|
+
endLine: index + 1,
|
|
7194
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
7195
|
+
}).pipe(Effect.map((one) => one.lines === 1 ? one.text : void 0), Effect.orElseSucceed(() => void 0));
|
|
7196
|
+
const end = Math.min(scan.value.totalLines, match.line + context);
|
|
7197
|
+
const block = [];
|
|
7198
|
+
let truncated = false;
|
|
7199
|
+
for (let current = startLine + 1; current <= end; current++) {
|
|
7200
|
+
const line = current === match.line ? match.text : yield* lineAt(current - 1);
|
|
7201
|
+
const { text, wasTruncated } = line === void 0 ? {
|
|
7202
|
+
text: "... [truncated]",
|
|
7203
|
+
wasTruncated: true
|
|
7204
|
+
} : shown(line);
|
|
7205
|
+
if (wasTruncated) truncated = true;
|
|
7206
|
+
block.push(current === match.line ? `${label}:${current}: ${text}` : `${label}-${current}- ${text}`);
|
|
7207
|
+
}
|
|
7208
|
+
return {
|
|
7209
|
+
lines: block,
|
|
7210
|
+
truncated
|
|
7211
|
+
};
|
|
7212
|
+
});
|
|
7213
|
+
const grepHandler = (params) => Effect.gen(function* () {
|
|
7214
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7215
|
+
const shell = yield* io_exports.SandboxIO.Shell;
|
|
7216
|
+
const sandbox = yield* io_exports.SandboxIO.Current;
|
|
7217
|
+
const searchPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
|
|
7218
|
+
const stat = yield* fs.stat(searchPath).pipe(Effect.option);
|
|
7219
|
+
if (stat._tag === "None") return yield* fail$2(`Path not found: ${searchPath}`);
|
|
7220
|
+
const isDirectory = stat.value.isDirectory;
|
|
7221
|
+
const context = params.context && params.context > 0 ? Math.trunc(params.context) : 0;
|
|
7222
|
+
const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT$1));
|
|
7223
|
+
const errors = yield* path$1(sandbox.spillPath, "codework-grep");
|
|
7224
|
+
const run = yield* withCommand("rg", sandbox, shell, fs, (command) => Effect.gen(function* () {
|
|
7225
|
+
const argv = [
|
|
7226
|
+
command,
|
|
7227
|
+
"--json",
|
|
7228
|
+
"--line-number",
|
|
7229
|
+
"--hidden",
|
|
7230
|
+
"--sort",
|
|
7231
|
+
"path"
|
|
7232
|
+
];
|
|
7233
|
+
argv.push(params.ignoreCase ? "--ignore-case" : "-s");
|
|
7234
|
+
if (params.literal) argv.push("--fixed-strings");
|
|
7235
|
+
if (params.glob) argv.push("--glob", params.glob);
|
|
7236
|
+
argv.push("-e", params.pattern, searchPath);
|
|
7237
|
+
const result = yield* shell.exec(pipeline(argv, limit, errors));
|
|
7238
|
+
const parsed = parse$2(result.stdout, result.stderr);
|
|
7239
|
+
return {
|
|
7240
|
+
...parsed,
|
|
7241
|
+
exitCode: parsed.status ?? result.exitCode
|
|
7242
|
+
};
|
|
7243
|
+
}));
|
|
7244
|
+
if ("_tag" in run) return yield* fail$2(run.message);
|
|
7245
|
+
const { matches } = run;
|
|
7246
|
+
const matchLimitReached = matches.length >= limit;
|
|
7247
|
+
if (!matchLimitReached && run.exitCode !== 0 && run.exitCode !== 1) return yield* fail$2(run.errors || `ripgrep exited with code ${run.exitCode}`);
|
|
7248
|
+
if (matches.length === 0) return { text: "No matches found" };
|
|
7249
|
+
const label = (path) => {
|
|
7250
|
+
if (isDirectory) {
|
|
7251
|
+
const relative = posix.relative(searchPath, path);
|
|
7252
|
+
if (relative && !relative.startsWith("..")) return relative;
|
|
7253
|
+
}
|
|
7254
|
+
return posix.basename(path);
|
|
7255
|
+
};
|
|
7256
|
+
const output = [];
|
|
7257
|
+
let linesTruncated = false;
|
|
7258
|
+
for (const match of matches) if (context === 0) {
|
|
7259
|
+
const { text, wasTruncated } = shown(match.text);
|
|
7260
|
+
if (wasTruncated) linesTruncated = true;
|
|
7261
|
+
output.push(`${label(match.path)}:${match.line}: ${text}`);
|
|
7262
|
+
} else {
|
|
7263
|
+
const block = yield* contextBlock(fs, match, context, label(match.path));
|
|
7264
|
+
if (block.truncated) linesTruncated = true;
|
|
7265
|
+
output.push(...block.lines);
|
|
7266
|
+
}
|
|
7267
|
+
const truncation = truncateHead(output.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
|
|
7268
|
+
const notices = [];
|
|
7269
|
+
const details = {};
|
|
7270
|
+
if (matchLimitReached) {
|
|
7271
|
+
notices.push(`${limit} matches limit reached. Use limit=${limit * 2} for more, or refine pattern`);
|
|
7272
|
+
details.matchLimitReached = limit;
|
|
7273
|
+
}
|
|
7274
|
+
if (truncation.truncated) {
|
|
7275
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
7276
|
+
details.truncation = truncation;
|
|
7277
|
+
}
|
|
7278
|
+
if (linesTruncated) {
|
|
7279
|
+
notices.push(`Some lines truncated to 500 chars. Use read tool to see full lines`);
|
|
7280
|
+
details.linesTruncated = true;
|
|
7281
|
+
}
|
|
7282
|
+
const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
|
|
7283
|
+
return Object.keys(details).length > 0 ? {
|
|
7284
|
+
text,
|
|
7285
|
+
details
|
|
7286
|
+
} : { text };
|
|
7287
|
+
}).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error)));
|
|
7288
|
+
/** The grep tool: definition + handler, wired the testable (def/exec split) way. */
|
|
7289
|
+
const grepTool = tool_exports.implement(grepDef, grepHandler);
|
|
7290
|
+
const grepPlugin = (0, plugin_exports$1.define)({
|
|
7291
|
+
id: "codework.tool.grep",
|
|
7292
|
+
kind: "tool",
|
|
7293
|
+
optIn: true,
|
|
7294
|
+
setup: Effect.fn("GrepPlugin.setup")(function* (ctx) {
|
|
7295
|
+
const mounted = Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Shell, yield* io_exports.SandboxIO.Shell), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current), Layer.succeed(HostBin, ctx.paths.bin));
|
|
7296
|
+
ctx.plugin.tools.add(tool_exports.provide(grepTool, mounted));
|
|
7297
|
+
})
|
|
7298
|
+
});
|
|
7299
|
+
//#endregion
|
|
7300
|
+
//#region src/plugin/builtin/tool/ls.ts
|
|
7301
|
+
/** The built-in ls plugin: one directory through the sandbox filesystem, no binaries involved. */
|
|
7302
|
+
const LsParams = Schema.Struct({
|
|
7303
|
+
path: Schema.optional(Schema.String.annotate({ description: "Directory to list (default: current directory)" })),
|
|
7304
|
+
limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of entries to return (default: 500)" }))
|
|
7305
|
+
});
|
|
7306
|
+
const LsDetails = Schema.Struct({
|
|
7307
|
+
truncation: Schema.optional(Schema.Unknown),
|
|
7308
|
+
entryLimitReached: Schema.optional(Schema.Finite)
|
|
7309
|
+
});
|
|
7310
|
+
const LsSuccess = Schema.Struct({
|
|
7311
|
+
text: Schema.String,
|
|
7312
|
+
details: Schema.optional(LsDetails)
|
|
7313
|
+
});
|
|
7314
|
+
var LsFailed = class extends Schema.TaggedError()("LsFailed", { message: Schema.String }) {};
|
|
7315
|
+
const DEFAULT_LIMIT = 500;
|
|
7316
|
+
const lsDef = tool_exports.define({
|
|
7317
|
+
name: "ls",
|
|
7318
|
+
label: "ls",
|
|
7319
|
+
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
|
|
7320
|
+
promptSnippet: "List directory contents",
|
|
7321
|
+
parameters: LsParams,
|
|
7322
|
+
constrainedSampling: {
|
|
7323
|
+
type: "json_schema",
|
|
7324
|
+
strict: "prefer"
|
|
7325
|
+
},
|
|
7326
|
+
success: LsSuccess,
|
|
7327
|
+
failure: LsFailed,
|
|
7328
|
+
encodeContent: (success) => [{
|
|
7329
|
+
type: "text",
|
|
7330
|
+
text: success.text
|
|
7331
|
+
}],
|
|
7332
|
+
encodeFailureContent: (failure) => [{
|
|
7333
|
+
type: "text",
|
|
7334
|
+
text: failure.message
|
|
7335
|
+
}],
|
|
7336
|
+
encodeDetails: (success) => success.details
|
|
7337
|
+
});
|
|
7338
|
+
const fail$1 = (message) => Effect.fail(new LsFailed({ message }));
|
|
7339
|
+
const lsHandler = (params) => Effect.gen(function* () {
|
|
7340
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7341
|
+
const sandbox = yield* io_exports.SandboxIO.Current;
|
|
7342
|
+
const dirPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
|
|
7343
|
+
const limit = params.limit ?? DEFAULT_LIMIT;
|
|
7344
|
+
const stat = yield* fs.stat(dirPath).pipe(Effect.option);
|
|
7345
|
+
if (stat._tag === "None") return yield* fail$1(`Path not found: ${dirPath}`);
|
|
7346
|
+
if (!stat.value.isDirectory) return yield* fail$1(`Not a directory: ${dirPath}`);
|
|
7347
|
+
const read = yield* fs.readdir(dirPath).pipe(Effect.result);
|
|
7348
|
+
if (read._tag === "Failure") {
|
|
7349
|
+
const cause = read.failure.cause;
|
|
7350
|
+
return yield* fail$1(`Cannot read directory: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
7351
|
+
}
|
|
7352
|
+
const entries = [...read.success].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
7353
|
+
const results = [];
|
|
7354
|
+
let entryLimitReached = false;
|
|
7355
|
+
for (const entry of entries) {
|
|
7356
|
+
if (results.length >= limit) {
|
|
7357
|
+
entryLimitReached = true;
|
|
7358
|
+
break;
|
|
7359
|
+
}
|
|
7360
|
+
const entryStat = yield* fs.stat(posix.join(dirPath, entry)).pipe(Effect.option);
|
|
7361
|
+
if (entryStat._tag === "None") continue;
|
|
7362
|
+
results.push(entryStat.value.isDirectory ? `${entry}/` : entry);
|
|
7363
|
+
}
|
|
7364
|
+
if (results.length === 0) return { text: "(empty directory)" };
|
|
7365
|
+
const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
|
|
7366
|
+
const notices = [];
|
|
7367
|
+
const details = {};
|
|
7368
|
+
if (entryLimitReached) {
|
|
7369
|
+
notices.push(`${limit} entries limit reached. Use limit=${limit * 2} for more`);
|
|
7370
|
+
details.entryLimitReached = limit;
|
|
7371
|
+
}
|
|
7372
|
+
if (truncation.truncated) {
|
|
7373
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
7374
|
+
details.truncation = truncation;
|
|
7375
|
+
}
|
|
7376
|
+
const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
|
|
7377
|
+
return Object.keys(details).length > 0 ? {
|
|
7378
|
+
text,
|
|
7379
|
+
details
|
|
7380
|
+
} : { text };
|
|
7381
|
+
});
|
|
7382
|
+
/** The ls tool: definition + handler, wired the testable (def/exec split) way. */
|
|
7383
|
+
const lsTool = tool_exports.implement(lsDef, lsHandler);
|
|
7384
|
+
const lsPlugin = (0, plugin_exports$1.define)({
|
|
7385
|
+
id: "codework.tool.ls",
|
|
7386
|
+
kind: "tool",
|
|
7387
|
+
optIn: true,
|
|
7388
|
+
setup: Effect.fn("LsPlugin.setup")(function* (ctx) {
|
|
7389
|
+
const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current));
|
|
7390
|
+
ctx.plugin.tools.add(tool_exports.provide(lsTool, mounted));
|
|
7391
|
+
})
|
|
7392
|
+
});
|
|
7393
|
+
//#endregion
|
|
7394
|
+
//#region src/tool/image/mime.ts
|
|
7395
|
+
/**
|
|
7396
|
+
* Image formats a model takes inline, told apart by their bytes, never by the
|
|
7397
|
+
* file name: a text file that starts with `GIF` is still text.
|
|
7398
|
+
*/
|
|
7399
|
+
const PNG_SIGNATURE = [
|
|
7400
|
+
137,
|
|
7401
|
+
80,
|
|
7402
|
+
78,
|
|
7403
|
+
71,
|
|
7404
|
+
13,
|
|
7405
|
+
10,
|
|
7406
|
+
26,
|
|
7407
|
+
10
|
|
7408
|
+
];
|
|
7409
|
+
/** Bytes every check except the APNG chunk walk needs: BMP reads up to offset 29. */
|
|
7410
|
+
const HEADER_BYTES = 32;
|
|
7411
|
+
const BLOCK_BYTES = 65536;
|
|
7412
|
+
/**
|
|
7413
|
+
* The image MIME type of a whole file, reading only its header and, for PNG, the
|
|
7414
|
+
* chunk headers up to the first `acTL` or `IDAT`. Animated PNGs are not images
|
|
7415
|
+
* here: a model would see only their first frame.
|
|
7416
|
+
*/
|
|
7417
|
+
const detectOf = (source) => Effect.gen(function* () {
|
|
7418
|
+
const header = yield* source.read(0, HEADER_BYTES);
|
|
7419
|
+
if (!startsWith(header, PNG_SIGNATURE)) return detect(header);
|
|
7420
|
+
return isPng(header) && !(yield* isAnimatedPngOf(source)) ? "image/png" : void 0;
|
|
7421
|
+
});
|
|
7422
|
+
const isAnimatedPngOf = (source) => Effect.gen(function* () {
|
|
7423
|
+
let block = /* @__PURE__ */ new Uint8Array(0);
|
|
7424
|
+
let blockStart = 0;
|
|
7425
|
+
let offset = PNG_SIGNATURE.length;
|
|
7426
|
+
while (offset + 8 <= source.size) {
|
|
7427
|
+
if (offset < blockStart || offset + 8 > blockStart + block.length) {
|
|
7428
|
+
blockStart = offset;
|
|
7429
|
+
block = yield* source.read(offset, BLOCK_BYTES);
|
|
7430
|
+
}
|
|
7431
|
+
const chunkHeader = block.subarray(offset - blockStart, offset - blockStart + 8);
|
|
7432
|
+
if (chunkHeader.length < 8) return false;
|
|
7433
|
+
if (startsWithAscii(chunkHeader, 4, "acTL")) return true;
|
|
7434
|
+
if (startsWithAscii(chunkHeader, 4, "IDAT")) return false;
|
|
7435
|
+
const nextOffset = offset + 8 + readUint32BE(chunkHeader, 0) + 4;
|
|
7436
|
+
if (nextOffset <= offset || nextOffset > source.size) return false;
|
|
7437
|
+
offset = nextOffset;
|
|
7438
|
+
}
|
|
7439
|
+
return false;
|
|
7440
|
+
});
|
|
7441
|
+
/** The image MIME type of a header; a PNG still needs {@link isAnimatedPngOf}. */
|
|
7442
|
+
const detect = (buffer) => {
|
|
7443
|
+
if (startsWith(buffer, [
|
|
7444
|
+
255,
|
|
7445
|
+
216,
|
|
7446
|
+
255
|
|
7447
|
+
])) return buffer[3] === 247 ? void 0 : "image/jpeg";
|
|
7448
|
+
if (startsWith(buffer, PNG_SIGNATURE)) return isPng(buffer) ? "image/png" : void 0;
|
|
7449
|
+
if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) return "image/gif";
|
|
7450
|
+
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) return "image/webp";
|
|
7451
|
+
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) return "image/bmp";
|
|
7452
|
+
};
|
|
7453
|
+
const isPng = (buffer) => buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
|
|
7454
|
+
const isBmp = (buffer) => {
|
|
7455
|
+
if (buffer.length < 26) return false;
|
|
7456
|
+
const declaredFileSize = readUint32LE(buffer, 2);
|
|
7457
|
+
const pixelDataOffset = readUint32LE(buffer, 10);
|
|
7458
|
+
const dibHeaderSize = readUint32LE(buffer, 14);
|
|
7459
|
+
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
|
|
7460
|
+
if (pixelDataOffset < 14 + dibHeaderSize) return false;
|
|
7461
|
+
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
|
|
7462
|
+
let colorPlanes;
|
|
7463
|
+
let bitsPerPixel;
|
|
7464
|
+
if (dibHeaderSize === 12) {
|
|
7465
|
+
colorPlanes = readUint16LE(buffer, 22);
|
|
7466
|
+
bitsPerPixel = readUint16LE(buffer, 24);
|
|
7467
|
+
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
|
|
7468
|
+
if (buffer.length < 30) return false;
|
|
7469
|
+
colorPlanes = readUint16LE(buffer, 26);
|
|
7470
|
+
bitsPerPixel = readUint16LE(buffer, 28);
|
|
7471
|
+
} else return false;
|
|
7472
|
+
return colorPlanes === 1 && [
|
|
7473
|
+
1,
|
|
7474
|
+
4,
|
|
7475
|
+
8,
|
|
7476
|
+
16,
|
|
7477
|
+
24,
|
|
7478
|
+
32
|
|
7479
|
+
].includes(bitsPerPixel);
|
|
7480
|
+
};
|
|
7481
|
+
const readUint16LE = (buffer, offset) => (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
|
|
7482
|
+
const readUint32BE = (buffer, offset) => (buffer[offset] ?? 0) * 16777216 + ((buffer[offset + 1] ?? 0) << 16) + ((buffer[offset + 2] ?? 0) << 8) + (buffer[offset + 3] ?? 0);
|
|
7483
|
+
const readUint32LE = (buffer, offset) => (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8) + ((buffer[offset + 2] ?? 0) << 16) + (buffer[offset + 3] ?? 0) * 16777216;
|
|
7484
|
+
const startsWith = (buffer, bytes) => buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
|
|
7485
|
+
const startsWithAscii = (buffer, offset, text) => {
|
|
7486
|
+
if (buffer.length < offset + text.length) return false;
|
|
7487
|
+
for (let index = 0; index < text.length; index++) if (buffer[offset + index] !== text.charCodeAt(index)) return false;
|
|
7488
|
+
return true;
|
|
7489
|
+
};
|
|
7490
|
+
//#endregion
|
|
7491
|
+
//#region src/tool/image/index.ts
|
|
7492
|
+
const INLINE = /* @__PURE__ */ new Set([
|
|
7493
|
+
"image/png",
|
|
7494
|
+
"image/jpeg",
|
|
7495
|
+
"image/gif",
|
|
7496
|
+
"image/webp"
|
|
7497
|
+
]);
|
|
7498
|
+
const toPng = (bytes) => load$4.pipe(Effect.map((photon) => {
|
|
7499
|
+
if (photon === void 0) return void 0;
|
|
7500
|
+
try {
|
|
7501
|
+
const image = decode(photon, bytes);
|
|
7502
|
+
try {
|
|
7503
|
+
return new Uint8Array(image.get_bytes());
|
|
7504
|
+
} finally {
|
|
7505
|
+
image.free();
|
|
7506
|
+
}
|
|
7507
|
+
} catch {
|
|
7508
|
+
return;
|
|
7509
|
+
}
|
|
7510
|
+
}));
|
|
7511
|
+
/**
|
|
7512
|
+
* Turn an image file into what a model takes inline: a supported format, within
|
|
7513
|
+
* the provider's dimension and size limits. A failure is a note for the model,
|
|
7514
|
+
* never an error — the read itself succeeded.
|
|
7515
|
+
*/
|
|
7516
|
+
const inline = Effect.fn("Image.inline")(function* (bytes, mimeType) {
|
|
7517
|
+
let source = {
|
|
7518
|
+
bytes,
|
|
7519
|
+
mimeType,
|
|
7520
|
+
convertedFrom: void 0
|
|
7521
|
+
};
|
|
7522
|
+
if (!INLINE.has(mimeType)) {
|
|
7523
|
+
const png = yield* toPng(bytes);
|
|
7524
|
+
if (png === void 0) return {
|
|
7525
|
+
ok: false,
|
|
7526
|
+
message: "[Image omitted: could not be converted to a supported inline image format.]"
|
|
7527
|
+
};
|
|
7528
|
+
source = {
|
|
7529
|
+
bytes: png,
|
|
7530
|
+
mimeType: "image/png",
|
|
7531
|
+
convertedFrom: mimeType
|
|
7532
|
+
};
|
|
7533
|
+
}
|
|
7534
|
+
const resized = yield* resize(source.bytes, source.mimeType);
|
|
7535
|
+
if (resized === null) return {
|
|
7536
|
+
ok: false,
|
|
7537
|
+
message: "[Image omitted: could not be resized below the inline image size limit.]"
|
|
7538
|
+
};
|
|
7539
|
+
const hints = [];
|
|
7540
|
+
if (source.convertedFrom !== void 0 && source.convertedFrom !== resized.mimeType) hints.push(`[Image converted from ${source.convertedFrom} to ${resized.mimeType}.]`);
|
|
7541
|
+
const note = dimensionNote(resized);
|
|
7542
|
+
if (note !== void 0) hints.push(note);
|
|
7543
|
+
return {
|
|
7544
|
+
ok: true,
|
|
7545
|
+
data: resized.data,
|
|
7546
|
+
mimeType: resized.mimeType,
|
|
7547
|
+
hints
|
|
7548
|
+
};
|
|
7549
|
+
});
|
|
7550
|
+
//#endregion
|
|
7551
|
+
//#region src/plugin/builtin/tool/read.ts
|
|
7552
|
+
/**
|
|
7553
|
+
* The built-in read plugin. Text is read through `scanLines`, so only the shown
|
|
7554
|
+
* window crosses from the sandbox; images, told apart by their bytes, go to the
|
|
7555
|
+
* model as attachments. Continuation notices are part of the text the model
|
|
7556
|
+
* reads; `details` keeps only how the text was cut.
|
|
7557
|
+
*/
|
|
7558
|
+
const ReadParams = Schema.Struct({
|
|
7559
|
+
path: Schema.String.annotate({ description: "Path to the file to read (relative or absolute)" }),
|
|
7560
|
+
offset: Schema.optional(Schema.Finite.annotate({ description: "Line number to start reading from (1-indexed)" })),
|
|
7561
|
+
limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of lines to read" }))
|
|
7562
|
+
});
|
|
7563
|
+
/** How the shown text was cut. */
|
|
7564
|
+
const Truncation = Schema.Struct({
|
|
7565
|
+
truncated: Schema.Boolean,
|
|
7566
|
+
truncatedBy: Schema.NullOr(Schema.Literals(["lines", "bytes"])),
|
|
7567
|
+
/** Lines selected by `offset`/`limit`, before the limits. */
|
|
7568
|
+
totalLines: Schema.Finite,
|
|
7569
|
+
outputLines: Schema.Finite,
|
|
7570
|
+
outputBytes: Schema.Finite,
|
|
7571
|
+
firstLineExceedsLimit: Schema.Boolean,
|
|
7572
|
+
maxLines: Schema.Finite,
|
|
7573
|
+
maxBytes: Schema.Finite
|
|
7574
|
+
});
|
|
7575
|
+
const ReadText = Schema.Struct({
|
|
7576
|
+
type: Schema.Literal("text"),
|
|
7577
|
+
text: Schema.String,
|
|
7578
|
+
truncation: Schema.optional(Truncation)
|
|
7579
|
+
});
|
|
7580
|
+
const ReadImage = Schema.Struct({
|
|
7581
|
+
type: Schema.Literal("image"),
|
|
7582
|
+
/** What the model reads with the image: its type and any conversion/resize hints. */
|
|
7583
|
+
note: Schema.String,
|
|
7584
|
+
image: Schema.optional(Schema.Struct({
|
|
7585
|
+
data: Schema.String,
|
|
7586
|
+
mimeType: Schema.String
|
|
7587
|
+
}))
|
|
7588
|
+
});
|
|
7589
|
+
const ReadSuccess = Schema.Union([ReadText, ReadImage]);
|
|
7590
|
+
var ReadFailed = class extends Schema.TaggedError()("ReadFailed", { message: Schema.String }) {};
|
|
7591
|
+
const BINARY_PROBE_BYTES = 8e3;
|
|
7592
|
+
const readDef = tool_exports.define({
|
|
7593
|
+
name: "read",
|
|
7594
|
+
label: "read",
|
|
7595
|
+
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
|
7596
|
+
promptSnippet: "Read file contents",
|
|
7597
|
+
promptGuidelines: ["Use read to examine files instead of cat or sed."],
|
|
7598
|
+
parameters: ReadParams,
|
|
7599
|
+
constrainedSampling: {
|
|
7600
|
+
type: "json_schema",
|
|
7601
|
+
strict: "prefer"
|
|
7602
|
+
},
|
|
7603
|
+
success: ReadSuccess,
|
|
7604
|
+
failure: ReadFailed,
|
|
7605
|
+
encodeContent: (success) => success.type === "text" ? [{
|
|
7606
|
+
type: "text",
|
|
7607
|
+
text: success.text
|
|
7608
|
+
}] : [{
|
|
7609
|
+
type: "text",
|
|
7610
|
+
text: success.note
|
|
7611
|
+
}, ...success.image === void 0 ? [] : [{
|
|
7612
|
+
type: "image",
|
|
7613
|
+
...success.image
|
|
7614
|
+
}]],
|
|
7615
|
+
encodeFailureContent: (failure) => [{
|
|
7616
|
+
type: "text",
|
|
7617
|
+
text: failure.message
|
|
7618
|
+
}],
|
|
7619
|
+
encodeDetails: (success) => success.type === "text" && success.truncation !== void 0 ? { truncation: success.truncation } : void 0
|
|
7620
|
+
});
|
|
7621
|
+
const fail = (message) => Effect.fail(new ReadFailed({ message }));
|
|
7622
|
+
/**
|
|
7623
|
+
* Filesystem failures are the model's to see, as Pi shows them: a missing path in
|
|
7624
|
+
* one wording on every backend, anything else (EACCES, …) as the backend reported it.
|
|
7625
|
+
*/
|
|
7626
|
+
const fsFailure$1 = (path) => (error) => fail(filesystem_exports.SandboxFileSystem.isNotFoundError(error.cause) ? `ENOENT: no such file or directory, open '${path}'` : error.cause instanceof Error ? error.cause.message : `Could not read file: ${path}`);
|
|
7627
|
+
const readImage = (fs, resolved, mimeType) => Effect.gen(function* () {
|
|
7628
|
+
const bytes = yield* fs.readFileBuffer(resolved);
|
|
7629
|
+
const inline$1 = yield* inline(bytes, mimeType);
|
|
7630
|
+
if (!inline$1.ok) return {
|
|
7631
|
+
type: "image",
|
|
7632
|
+
note: `Read image file [${mimeType}]\n${inline$1.message}`
|
|
7633
|
+
};
|
|
7634
|
+
return {
|
|
7635
|
+
type: "image",
|
|
7636
|
+
note: [`Read image file [${inline$1.mimeType}]`, ...inline$1.hints].join("\n"),
|
|
7637
|
+
image: {
|
|
7638
|
+
data: inline$1.data,
|
|
7639
|
+
mimeType: inline$1.mimeType
|
|
7640
|
+
}
|
|
7641
|
+
};
|
|
7642
|
+
});
|
|
7643
|
+
const readText = (fs, resolved, params) => Effect.gen(function* () {
|
|
7644
|
+
const { path, offset } = params;
|
|
7645
|
+
const startLine = offset ? Math.max(0, Math.trunc(offset) - 1) : 0;
|
|
7646
|
+
const limit = params.limit === void 0 ? void 0 : Math.max(1, Math.trunc(params.limit));
|
|
7647
|
+
const endLine = startLine + Math.min(limit ?? 2e3, DEFAULT_MAX_LINES);
|
|
7648
|
+
const scan = yield* fs.scanLines(resolved, Number.isSafeInteger(endLine) ? {
|
|
7649
|
+
startLine,
|
|
7650
|
+
endLine,
|
|
7651
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
7652
|
+
} : {
|
|
7653
|
+
startLine: 0,
|
|
7654
|
+
endLine: 1,
|
|
7655
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
7656
|
+
});
|
|
7657
|
+
if (startLine >= Math.max(scan.totalLines, 1)) return yield* fail(`Offset ${offset} is beyond end of file (${scan.totalLines} lines total)`);
|
|
7658
|
+
const grown = Buffer.byteLength(scan.text, "utf8") > 51200 ? truncateHead(scan.text) : void 0;
|
|
7659
|
+
const firstLineBytes = grown?.firstLineExceedsLimit ? Buffer.byteLength(scan.text.split("\n", 1)[0] ?? "", "utf8") : scan.firstLineBytes;
|
|
7660
|
+
const shown = grown === void 0 ? scan : {
|
|
7661
|
+
text: grown.content,
|
|
7662
|
+
lines: grown.outputLines
|
|
7663
|
+
};
|
|
7664
|
+
const first = startLine + 1;
|
|
7665
|
+
const selected = Math.min(scan.totalLines, limit === void 0 ? Infinity : startLine + limit) - startLine;
|
|
7666
|
+
const truncation = (truncatedBy, firstLineExceedsLimit) => ({
|
|
7667
|
+
truncated: true,
|
|
7668
|
+
truncatedBy,
|
|
7669
|
+
totalLines: selected,
|
|
7670
|
+
outputLines: shown.lines,
|
|
7671
|
+
outputBytes: Buffer.byteLength(shown.text, "utf8"),
|
|
7672
|
+
firstLineExceedsLimit,
|
|
7673
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
7674
|
+
maxBytes: DEFAULT_MAX_BYTES
|
|
7675
|
+
});
|
|
7676
|
+
if (firstLineBytes > 51200) return {
|
|
7677
|
+
type: "text",
|
|
7678
|
+
text: `[Line ${first} is ${formatSize(firstLineBytes)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${first}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`,
|
|
7679
|
+
truncation: {
|
|
7680
|
+
...truncation("bytes", true),
|
|
7681
|
+
outputLines: 0,
|
|
7682
|
+
outputBytes: 0
|
|
7683
|
+
}
|
|
7684
|
+
};
|
|
7685
|
+
if (shown.lines < selected) {
|
|
7686
|
+
const last = startLine + shown.lines;
|
|
7687
|
+
const truncatedBy = shown.lines < Math.min(selected, 2e3) ? "bytes" : "lines";
|
|
7688
|
+
const limitText = truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : "";
|
|
7689
|
+
return {
|
|
7690
|
+
type: "text",
|
|
7691
|
+
text: `${shown.text}\n\n[Showing lines ${first}-${last} of ${scan.totalLines}${limitText}. Use offset=${last + 1} to continue.]`,
|
|
7692
|
+
truncation: truncation(truncatedBy, false)
|
|
7693
|
+
};
|
|
7694
|
+
}
|
|
7695
|
+
if (limit !== void 0 && startLine + limit < scan.totalLines) {
|
|
7696
|
+
const remaining = scan.totalLines - (startLine + limit);
|
|
7697
|
+
return {
|
|
7698
|
+
type: "text",
|
|
7699
|
+
text: `${shown.text}\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue.]`
|
|
7700
|
+
};
|
|
7701
|
+
}
|
|
7702
|
+
return {
|
|
7703
|
+
type: "text",
|
|
7704
|
+
text: shown.text
|
|
7705
|
+
};
|
|
7706
|
+
});
|
|
7707
|
+
const readHandler = (params) => Effect.gen(function* () {
|
|
7708
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7709
|
+
const resolved = yield* resolveRead(fs, params.path);
|
|
7710
|
+
const stat = yield* fs.stat(resolved);
|
|
7711
|
+
if (stat.isDirectory) return yield* fail("EISDIR: illegal operation on a directory, read");
|
|
7712
|
+
if (!stat.isFile) return yield* fail("Not a regular file");
|
|
7713
|
+
const head = yield* fs.readBytes(resolved, 0, BINARY_PROBE_BYTES);
|
|
7714
|
+
const mimeType = yield* detectOf({
|
|
7715
|
+
size: stat.size ?? Infinity,
|
|
7716
|
+
read: (offset, length) => offset + length <= head.length ? Effect.succeed(head.subarray(offset, offset + length)) : fs.readBytes(resolved, offset, length)
|
|
7717
|
+
});
|
|
7718
|
+
if (mimeType !== void 0) return yield* readImage(fs, resolved, mimeType);
|
|
7719
|
+
if (head.includes(0)) return yield* fail(`Unsupported read ${params.path}: binary file.`);
|
|
7720
|
+
return yield* readText(fs, resolved, params);
|
|
7721
|
+
}).pipe(Effect.catchTag("SandboxFileSystemError", fsFailure$1(params.path)));
|
|
7722
|
+
/** The read tool: definition + handler, wired the testable (def/exec split) way. */
|
|
7723
|
+
const readTool = tool_exports.implement(readDef, readHandler);
|
|
7724
|
+
const readPlugin = (0, plugin_exports$1.define)({
|
|
7725
|
+
id: "codework.tool.read",
|
|
7726
|
+
kind: "tool",
|
|
7727
|
+
setup: Effect.fn("ReadPlugin.setup")(function* (ctx) {
|
|
7728
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7729
|
+
ctx.plugin.tools.add(tool_exports.provide(readTool, Layer.succeed(io_exports.SandboxIO.FileSystem, fs)));
|
|
7730
|
+
})
|
|
7731
|
+
});
|
|
7732
|
+
//#endregion
|
|
7733
|
+
//#region src/plugin/builtin/tool/write.ts
|
|
7734
|
+
/**
|
|
7735
|
+
* The built-in write plugin. The whole write holds the file's mutation lock, so
|
|
7736
|
+
* it never interleaves with an `edit` of the same file; the filesystem creates
|
|
7737
|
+
* missing parent directories on every backend.
|
|
7738
|
+
*/
|
|
7739
|
+
const WriteParams = Schema.Struct({
|
|
7740
|
+
path: Schema.String.annotate({ description: "Path to the file to write (relative or absolute)" }),
|
|
7741
|
+
content: Schema.String.annotate({ description: "Content to write to the file" })
|
|
7742
|
+
});
|
|
7743
|
+
var WriteFailed = class extends Schema.TaggedError()("WriteFailed", { message: Schema.String }) {};
|
|
7744
|
+
const writeDef = tool_exports.define({
|
|
7745
|
+
name: "write",
|
|
7746
|
+
label: "write",
|
|
7747
|
+
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
|
7748
|
+
promptSnippet: "Create or overwrite files",
|
|
7749
|
+
promptGuidelines: ["Use write only for new files or complete rewrites."],
|
|
7750
|
+
parameters: WriteParams,
|
|
7751
|
+
constrainedSampling: {
|
|
7752
|
+
type: "json_schema",
|
|
7753
|
+
strict: "prefer"
|
|
7754
|
+
},
|
|
7755
|
+
success: Schema.Struct({ path: Schema.String }),
|
|
7756
|
+
failure: WriteFailed,
|
|
7757
|
+
encodeContent: (success) => [{
|
|
7758
|
+
type: "text",
|
|
7759
|
+
text: `Successfully wrote to ${success.path}`
|
|
7760
|
+
}],
|
|
7761
|
+
encodeFailureContent: (failure) => [{
|
|
7762
|
+
type: "text",
|
|
7763
|
+
text: failure.message
|
|
7764
|
+
}],
|
|
7765
|
+
encodeDetails: () => void 0
|
|
7766
|
+
});
|
|
7767
|
+
/** Filesystem failures are the model's to see, as the backend reported them. */
|
|
7768
|
+
const fsFailure = (path) => (error) => Effect.fail(new WriteFailed({ message: error.cause instanceof Error ? error.cause.message : `Could not write file: ${path}` }));
|
|
7769
|
+
const writeHandler = (params) => Effect.gen(function* () {
|
|
7770
|
+
const fs = yield* io_exports.SandboxIO.FileSystem;
|
|
7771
|
+
const mutation = yield* io_exports.SandboxIO.Mutation;
|
|
7772
|
+
const path = normalize$1(params.path);
|
|
7773
|
+
yield* mutation.withFile(path, fs.writeFile(path, params.content));
|
|
7774
|
+
return { path: params.path };
|
|
7775
|
+
}).pipe(Effect.catchTag("SandboxFileSystemError", fsFailure(params.path)));
|
|
7776
|
+
/** The write tool: definition + handler, wired the testable (def/exec split) way. */
|
|
7777
|
+
const writeTool = tool_exports.implement(writeDef, writeHandler);
|
|
7778
|
+
const writePlugin = (0, plugin_exports$1.define)({
|
|
7779
|
+
id: "codework.tool.write",
|
|
7780
|
+
kind: "tool",
|
|
7781
|
+
setup: Effect.fn("WritePlugin.setup")(function* (ctx) {
|
|
7782
|
+
const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Mutation, yield* io_exports.SandboxIO.Mutation));
|
|
7783
|
+
ctx.plugin.tools.add(tool_exports.provide(writeTool, mounted));
|
|
7784
|
+
})
|
|
7785
|
+
});
|
|
7786
|
+
//#endregion
|
|
6123
7787
|
//#region src/plugin/builtin.ts
|
|
6124
7788
|
/**
|
|
6125
7789
|
* Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
|
|
6126
|
-
* contributors come first; within the prompt domain, declaration order is run order.
|
|
7790
|
+
* contributors come first; within the prompt domain, declaration order is run order. The search
|
|
7791
|
+
* tools (grep, find, ls) are `optIn`, as Pi ships them off: settings turn each on with
|
|
7792
|
+
* `{ "plugin": "codework.tool.grep", "enabled": true }`.
|
|
6127
7793
|
*/
|
|
6128
7794
|
const builtins = Object.freeze([
|
|
6129
7795
|
bashPlugin,
|
|
7796
|
+
readPlugin,
|
|
7797
|
+
writePlugin,
|
|
7798
|
+
editPlugin,
|
|
7799
|
+
grepPlugin,
|
|
7800
|
+
findPlugin,
|
|
7801
|
+
lsPlugin,
|
|
6130
7802
|
defaultPromptPlugin,
|
|
6131
7803
|
instructionPlugin
|
|
6132
7804
|
]);
|
|
@@ -7208,6 +8880,7 @@ const Definition = Schema.Struct({
|
|
|
7208
8880
|
id: Id,
|
|
7209
8881
|
kind: Schema.Literals(Object.keys(plugin_exports$1.domains)),
|
|
7210
8882
|
events: Schema.optional(Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "type") && Predicate.isString(value.type)))),
|
|
8883
|
+
optIn: Schema.optional(Schema.Boolean),
|
|
7211
8884
|
setup: Schema.declare((value) => Predicate.isFunction(value))
|
|
7212
8885
|
});
|
|
7213
8886
|
/** Every plugin failure says which file to go and edit, when there is one. */
|
|
@@ -7493,11 +9166,14 @@ const load$1 = Effect.fn("PluginCatalog.load")(function* (references, options) {
|
|
|
7493
9166
|
const select = Effect.fn("PluginCatalog.select")(function* (references, pool) {
|
|
7494
9167
|
const operations = /* @__PURE__ */ new Map();
|
|
7495
9168
|
const missing = [];
|
|
7496
|
-
const choose = (id, index) =>
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
9169
|
+
const choose = (id, index) => {
|
|
9170
|
+
const previous = operations.get(id);
|
|
9171
|
+
operations.set(id, {
|
|
9172
|
+
enabled: pool.plugins.get(id)?.optIn === true ? previous?.enabled ?? false : true,
|
|
9173
|
+
index,
|
|
9174
|
+
options: previous?.options ?? {}
|
|
9175
|
+
});
|
|
9176
|
+
};
|
|
7501
9177
|
for (const [index, reference] of references.entries()) {
|
|
7502
9178
|
if (isPatch(reference)) {
|
|
7503
9179
|
const named = reference.plugin ?? reference.package;
|
|
@@ -7812,7 +9488,8 @@ function resolveRequest(block, model) {
|
|
|
7812
9488
|
*
|
|
7813
9489
|
* Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
|
|
7814
9490
|
* think, how to schedule tool calls -- rather than the request body. Runtime bindings
|
|
7815
|
-
* outrank a matched block, which outranks the file's top level.
|
|
9491
|
+
* outrank a matched block, which outranks the file's top level. The thinking level is the
|
|
9492
|
+
* requested one; State clamps it to the model once the catalog lookup is in hand.
|
|
7816
9493
|
*/
|
|
7817
9494
|
const resolveControls = (settings, block, runtime) => ({
|
|
7818
9495
|
thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
|
|
@@ -7833,6 +9510,25 @@ function compose(settings, options = {}, session = {}) {
|
|
|
7833
9510
|
};
|
|
7834
9511
|
}
|
|
7835
9512
|
//#endregion
|
|
9513
|
+
//#region src/runner/attribution.ts
|
|
9514
|
+
const OPENROUTER_HOST = "openrouter.ai";
|
|
9515
|
+
const isOpenRouter = (model) => model.provider.id === "openrouter" || (model.api?.url ?? model.baseUrl).includes(OPENROUTER_HOST);
|
|
9516
|
+
const defaults = (model) => isOpenRouter(model) ? {
|
|
9517
|
+
"HTTP-Referer": "https://codework.sh",
|
|
9518
|
+
"X-OpenRouter-Title": "CodeWork",
|
|
9519
|
+
"X-OpenRouter-Categories": "cli-agent,personal-agent"
|
|
9520
|
+
} : void 0;
|
|
9521
|
+
const withAttribution = (model) => {
|
|
9522
|
+
const headers = defaults(model);
|
|
9523
|
+
return headers === void 0 ? model : {
|
|
9524
|
+
...model,
|
|
9525
|
+
headers: {
|
|
9526
|
+
...headers,
|
|
9527
|
+
...model.headers
|
|
9528
|
+
}
|
|
9529
|
+
};
|
|
9530
|
+
};
|
|
9531
|
+
//#endregion
|
|
7836
9532
|
//#region src/runner/event.ts
|
|
7837
9533
|
const make$6 = Effect.fn("LLMEventPublisher.make")(function* (input) {
|
|
7838
9534
|
const events = yield* Service$16;
|
|
@@ -8087,7 +9783,7 @@ const oauthCredentials = async (protocol, options) => {
|
|
|
8087
9783
|
};
|
|
8088
9784
|
/** Start a provider stream using the model pinned by State. */
|
|
8089
9785
|
const openWith = (options = {}) => Effect.fn("LLM.open")(function* (input, signal) {
|
|
8090
|
-
const model = input.resolvedModel;
|
|
9786
|
+
const model = withAttribution(input.resolvedModel);
|
|
8091
9787
|
const configured = runtimeOptions(input, model, signal);
|
|
8092
9788
|
const resolved = configured.apiKey === void 0 ? yield* Effect.tryPromise({
|
|
8093
9789
|
try: () => oauthCredentials(model.protocol, options),
|
|
@@ -8220,8 +9916,9 @@ const encodeOutcome = (def, call, exit, latest) => Effect.gen(function* () {
|
|
|
8220
9916
|
if (Exit.isSuccess(exit)) {
|
|
8221
9917
|
const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.success))(exit.value).pipe(Effect.orDie);
|
|
8222
9918
|
const content = def.encodeContent ? def.encodeContent(exit.value) : [yield* jsonText(encoded)];
|
|
9919
|
+
const details = def.encodeDetails ? def.encodeDetails(exit.value) : encoded;
|
|
8223
9920
|
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
8224
|
-
return completed(call, content, now,
|
|
9921
|
+
return completed(call, content, now, details);
|
|
8225
9922
|
}
|
|
8226
9923
|
const cause = exit.cause;
|
|
8227
9924
|
if (Cause.hasInterrupts(cause)) {
|
|
@@ -8286,6 +9983,15 @@ const make$4 = (tools) => {
|
|
|
8286
9983
|
impls.set(name, entry);
|
|
8287
9984
|
}
|
|
8288
9985
|
const wire = [...impls.values()].map(({ tool }) => (0, tool_exports.toAikitTool)(tool.definition));
|
|
9986
|
+
const decoders = new Map(wire.map((tool) => {
|
|
9987
|
+
const decode = Schema.decodeUnknownEffect(Schema.toCodecJson(asCodec(impls.get(tool.name).tool.definition.parameters)));
|
|
9988
|
+
const parse = (args) => {
|
|
9989
|
+
const normalized = structuredClone(args);
|
|
9990
|
+
normalizeOptionalNulls(normalized, tool.parameters);
|
|
9991
|
+
return decode(normalized);
|
|
9992
|
+
};
|
|
9993
|
+
return [tool.name, parse];
|
|
9994
|
+
}));
|
|
8289
9995
|
const handle = (call, options) => Effect.gen(function* () {
|
|
8290
9996
|
const entry = impls.get(call.name);
|
|
8291
9997
|
if (entry === void 0) {
|
|
@@ -8297,7 +10003,18 @@ const make$4 = (tools) => {
|
|
|
8297
10003
|
}
|
|
8298
10004
|
const { tool: impl, hooks } = entry;
|
|
8299
10005
|
const def = impl.definition;
|
|
8300
|
-
const
|
|
10006
|
+
const prepared = yield* Effect.result(Effect.try({
|
|
10007
|
+
try: () => def.prepareArguments ? def.prepareArguments(call.arguments) : call.arguments,
|
|
10008
|
+
catch: (cause) => cause instanceof Error ? cause.message : String(cause)
|
|
10009
|
+
}));
|
|
10010
|
+
if (Result.isFailure(prepared)) {
|
|
10011
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
10012
|
+
return errored(call, [text(`Invalid arguments for ${call.name}: ${prepared.failure}`)], now, {
|
|
10013
|
+
error: "invalid_arguments",
|
|
10014
|
+
name: call.name
|
|
10015
|
+
});
|
|
10016
|
+
}
|
|
10017
|
+
const decoded = yield* Effect.result(decoders.get(call.name)(prepared.success));
|
|
8301
10018
|
if (Result.isFailure(decoded)) {
|
|
8302
10019
|
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
8303
10020
|
return errored(call, [text(`Invalid arguments for ${call.name}: ${decoded.failure.message}`)], now, {
|
|
@@ -8568,6 +10285,8 @@ const assertTool = (tool) => {
|
|
|
8568
10285
|
if (def.failure !== void 0 && !Schema.isSchema(def.failure)) throw new Error(`Tool ${name}: failure must be an Effect Schema`);
|
|
8569
10286
|
if (def.encodeContent !== void 0 && !Predicate.isFunction(def.encodeContent)) throw new Error(`Tool ${name}: encodeContent must be a function`);
|
|
8570
10287
|
if (def.encodeFailureContent !== void 0 && !Predicate.isFunction(def.encodeFailureContent)) throw new Error(`Tool ${name}: encodeFailureContent must be a function`);
|
|
10288
|
+
if (def.prepareArguments !== void 0 && !Predicate.isFunction(def.prepareArguments)) throw new Error(`Tool ${name}: prepareArguments must be a function`);
|
|
10289
|
+
if (def.encodeDetails !== void 0 && !Predicate.isFunction(def.encodeDetails)) throw new Error(`Tool ${name}: encodeDetails must be a function`);
|
|
8571
10290
|
if (!Predicate.isFunction(tool.handler)) throw new Error(`Tool ${name}: handler must be a function`);
|
|
8572
10291
|
};
|
|
8573
10292
|
const assertHooks = (name, hooks) => {
|
|
@@ -8783,6 +10502,7 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
|
|
|
8783
10502
|
const eventRegistry = yield* Service$4;
|
|
8784
10503
|
const eventService = yield* Service$16;
|
|
8785
10504
|
const events = makeEvents(eventService);
|
|
10505
|
+
const global = yield* Service$17;
|
|
8786
10506
|
const loading = yield* Semaphore.make(1);
|
|
8787
10507
|
const updated = (origin, id, status) => eventService.publish(PluginUpdated, {
|
|
8788
10508
|
status,
|
|
@@ -8846,10 +10566,22 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
|
|
|
8846
10566
|
...Option.getOrElse(yield* runtime.get(sessionId), () => ({}))
|
|
8847
10567
|
};
|
|
8848
10568
|
const loadedSettings = yield* settings.load(hostDir);
|
|
10569
|
+
const composed = compose(loadedSettings, options, sessionOptions);
|
|
10570
|
+
const resolvedModel = yield* resolve$1({
|
|
10571
|
+
provider: composed.provider,
|
|
10572
|
+
model: composed.model,
|
|
10573
|
+
settings: composed.block,
|
|
10574
|
+
models: loadedSettings.models
|
|
10575
|
+
});
|
|
10576
|
+
const thinkingLevel = Model.clampThinkingLevel(resolvedModel, composed.thinkingLevel);
|
|
8849
10577
|
return {
|
|
8850
10578
|
hostDir,
|
|
8851
10579
|
loadedSettings,
|
|
8852
|
-
configured:
|
|
10580
|
+
configured: {
|
|
10581
|
+
...composed,
|
|
10582
|
+
resolvedModel,
|
|
10583
|
+
thinkingLevel
|
|
10584
|
+
}
|
|
8853
10585
|
};
|
|
8854
10586
|
});
|
|
8855
10587
|
return Service$1.of({
|
|
@@ -8881,16 +10613,10 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
|
|
|
8881
10613
|
snapshot: Effect.fn("State.snapshot")(function* (sessionId) {
|
|
8882
10614
|
const { hostDir, loadedSettings, configured } = yield* configure(sessionId);
|
|
8883
10615
|
const { promptCustom, promptSystemAppend, provider: _provider, model: _model, thinkingLevel: _thinkingLevel, toolExecution: _toolExecution, ...rest } = configured.runtime;
|
|
8884
|
-
const { provider, model, thinkingLevel, toolExecution } = configured;
|
|
10616
|
+
const { provider, model, resolvedModel, thinkingLevel, toolExecution } = configured;
|
|
8885
10617
|
const request = merge(resolveOptions(configured.block), rest);
|
|
8886
10618
|
const sandbox = yield* io_exports.SandboxIO.Current;
|
|
8887
10619
|
const location = yield* Service$8;
|
|
8888
|
-
const resolvedModel = yield* resolve$1({
|
|
8889
|
-
provider,
|
|
8890
|
-
model,
|
|
8891
|
-
settings: configured.block,
|
|
8892
|
-
models: loadedSettings.models
|
|
8893
|
-
});
|
|
8894
10620
|
const refs = references(loadedSettings);
|
|
8895
10621
|
const failed = (cause) => new SnapshotError({
|
|
8896
10622
|
sessionId,
|
|
@@ -8925,6 +10651,7 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
|
|
|
8925
10651
|
...yield* run(chosen.selection, {
|
|
8926
10652
|
sessionId,
|
|
8927
10653
|
sandbox,
|
|
10654
|
+
paths: global,
|
|
8928
10655
|
location,
|
|
8929
10656
|
settings: loadedSettings,
|
|
8930
10657
|
model: resolvedModel,
|
|
@@ -9467,7 +11194,7 @@ var harness_exports = /* @__PURE__ */ __exportAll({
|
|
|
9467
11194
|
});
|
|
9468
11195
|
const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
|
|
9469
11196
|
const hostCwd = options.hostCwd ?? process.cwd();
|
|
9470
|
-
const paths = yield* resolve$
|
|
11197
|
+
const paths = yield* resolve$4(options.home === void 0 ? {} : { home: hostPath.resolve(hostCwd, expandTilde(options.home, hostPath)) });
|
|
9471
11198
|
const global = layerWith(paths);
|
|
9472
11199
|
const settingsOptions = options.userConfigDir === void 0 ? {} : { userConfigDir: userConfigDir(options.userConfigDir, hostCwd) };
|
|
9473
11200
|
const config = yield* load$3({
|