@llm4ts/runner 0.1.0
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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/Cli.d.ts +5 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +36 -0
- package/dist/Cli.js.map +1 -0
- package/dist/CoderPolicy.d.ts +12 -0
- package/dist/CoderPolicy.d.ts.map +1 -0
- package/dist/CoderPolicy.js +59 -0
- package/dist/CoderPolicy.js.map +1 -0
- package/dist/Connectors.d.ts +28 -0
- package/dist/Connectors.d.ts.map +1 -0
- package/dist/Connectors.js +165 -0
- package/dist/Connectors.js.map +1 -0
- package/dist/Doctor.d.ts +4 -0
- package/dist/Doctor.d.ts.map +1 -0
- package/dist/Doctor.js +33 -0
- package/dist/Doctor.js.map +1 -0
- package/dist/FlowArgs.d.ts +34 -0
- package/dist/FlowArgs.d.ts.map +1 -0
- package/dist/FlowArgs.js +136 -0
- package/dist/FlowArgs.js.map +1 -0
- package/dist/FlowRunner.d.ts +48 -0
- package/dist/FlowRunner.d.ts.map +1 -0
- package/dist/FlowRunner.js +97 -0
- package/dist/FlowRunner.js.map +1 -0
- package/dist/McpStdio.d.ts +7 -0
- package/dist/McpStdio.d.ts.map +1 -0
- package/dist/McpStdio.js +19 -0
- package/dist/McpStdio.js.map +1 -0
- package/dist/NodeGeminiCliExecutor.d.ts +7 -0
- package/dist/NodeGeminiCliExecutor.d.ts.map +1 -0
- package/dist/NodeGeminiCliExecutor.js +26 -0
- package/dist/NodeGeminiCliExecutor.js.map +1 -0
- package/dist/NodeHttpClient.d.ts +5 -0
- package/dist/NodeHttpClient.d.ts.map +1 -0
- package/dist/NodeHttpClient.js +109 -0
- package/dist/NodeHttpClient.js.map +1 -0
- package/dist/NodePlainFileStore.d.ts +5 -0
- package/dist/NodePlainFileStore.d.ts.map +1 -0
- package/dist/NodePlainFileStore.js +64 -0
- package/dist/NodePlainFileStore.js.map +1 -0
- package/dist/NodeProcessExecutor.d.ts +5 -0
- package/dist/NodeProcessExecutor.d.ts.map +1 -0
- package/dist/NodeProcessExecutor.js +212 -0
- package/dist/NodeProcessExecutor.js.map +1 -0
- package/dist/NodeRuntime.d.ts +4 -0
- package/dist/NodeRuntime.d.ts.map +1 -0
- package/dist/NodeRuntime.js +11 -0
- package/dist/NodeRuntime.js.map +1 -0
- package/dist/NodeTemporaryFiles.d.ts +5 -0
- package/dist/NodeTemporaryFiles.d.ts.map +1 -0
- package/dist/NodeTemporaryFiles.js +30 -0
- package/dist/NodeTemporaryFiles.js.map +1 -0
- package/dist/NodeWorkspace.d.ts +6 -0
- package/dist/NodeWorkspace.d.ts.map +1 -0
- package/dist/NodeWorkspace.js +231 -0
- package/dist/NodeWorkspace.js.map +1 -0
- package/dist/Package.d.ts +2 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +2 -0
- package/dist/Package.js.map +1 -0
- package/dist/ReviewFingerprint.d.ts +2 -0
- package/dist/ReviewFingerprint.d.ts.map +1 -0
- package/dist/ReviewFingerprint.js +13 -0
- package/dist/ReviewFingerprint.js.map +1 -0
- package/dist/Terminal.d.ts +46 -0
- package/dist/Terminal.d.ts.map +1 -0
- package/dist/Terminal.js +226 -0
- package/dist/Terminal.js.map +1 -0
- package/dist/TerminalInteraction.d.ts +4 -0
- package/dist/TerminalInteraction.d.ts.map +1 -0
- package/dist/TerminalInteraction.js +25 -0
- package/dist/TerminalInteraction.js.map +1 -0
- package/dist/cli-main.d.ts +3 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +39 -0
- package/dist/cli-main.js.map +1 -0
- package/package.json +74 -0
- package/src/Cli.ts +51 -0
- package/src/CoderPolicy.ts +82 -0
- package/src/Connectors.ts +225 -0
- package/src/Doctor.ts +44 -0
- package/src/FlowArgs.ts +167 -0
- package/src/FlowRunner.ts +220 -0
- package/src/McpStdio.ts +41 -0
- package/src/NodeGeminiCliExecutor.ts +56 -0
- package/src/NodeHttpClient.ts +224 -0
- package/src/NodePlainFileStore.ts +75 -0
- package/src/NodeProcessExecutor.ts +331 -0
- package/src/NodeRuntime.ts +19 -0
- package/src/NodeTemporaryFiles.ts +46 -0
- package/src/NodeWorkspace.ts +282 -0
- package/src/Package.ts +1 -0
- package/src/ReviewFingerprint.ts +13 -0
- package/src/Terminal.ts +329 -0
- package/src/TerminalInteraction.ts +43 -0
- package/src/cli-main.ts +53 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Layer from "effect/Layer"
|
|
6
|
+
import { TemporaryFiles, type TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
|
|
7
|
+
import { ProviderError } from "@llm4ts/core/Errors"
|
|
8
|
+
|
|
9
|
+
interface TemporaryFile {
|
|
10
|
+
readonly directory: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const errorMessage = (error: unknown): string =>
|
|
15
|
+
error instanceof Error ? error.message : String(error)
|
|
16
|
+
|
|
17
|
+
export const nodeTemporaryFiles: TemporaryFilesShape = {
|
|
18
|
+
write: (prefix, suffix, contents) =>
|
|
19
|
+
Effect.acquireRelease(
|
|
20
|
+
Effect.tryPromise({
|
|
21
|
+
try: async (): Promise<TemporaryFile> => {
|
|
22
|
+
const directory = await mkdtemp(join(tmpdir(), prefix))
|
|
23
|
+
const path = join(directory, `value${suffix}`)
|
|
24
|
+
await writeFile(path, contents, "utf8")
|
|
25
|
+
return {
|
|
26
|
+
directory,
|
|
27
|
+
path
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
catch: (error) =>
|
|
31
|
+
ProviderError.make({
|
|
32
|
+
message: `Failed to create temporary file: ${errorMessage(error)}`,
|
|
33
|
+
cause: error
|
|
34
|
+
})
|
|
35
|
+
}),
|
|
36
|
+
({ directory }) =>
|
|
37
|
+
Effect.promise(() =>
|
|
38
|
+
rm(directory, {
|
|
39
|
+
recursive: true,
|
|
40
|
+
force: true
|
|
41
|
+
})
|
|
42
|
+
).pipe(Effect.catchCause(() => Effect.void))
|
|
43
|
+
).pipe(Effect.map(({ path }) => path))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const NodeTemporaryFilesLive = Layer.succeed(TemporaryFiles, nodeTemporaryFiles)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
readdir,
|
|
6
|
+
realpath,
|
|
7
|
+
stat,
|
|
8
|
+
writeFile,
|
|
9
|
+
appendFile
|
|
10
|
+
} from "node:fs/promises"
|
|
11
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import * as Layer from "effect/Layer"
|
|
14
|
+
import {
|
|
15
|
+
SearchMatch,
|
|
16
|
+
Workspace,
|
|
17
|
+
defaultWorkspaceLimits,
|
|
18
|
+
type WorkspaceError,
|
|
19
|
+
type WorkspaceLimits,
|
|
20
|
+
type WorkspaceShape
|
|
21
|
+
} from "@llm4ts/flow/Workspace"
|
|
22
|
+
import { WorkspaceIoError, WorkspaceLimitError, WorkspacePathError } from "@llm4ts/flow/FlowError"
|
|
23
|
+
|
|
24
|
+
const errorMessage = (error: unknown): string =>
|
|
25
|
+
error instanceof Error ? error.message : String(error)
|
|
26
|
+
|
|
27
|
+
const isMissing = (error: unknown): boolean =>
|
|
28
|
+
error instanceof Error && "code" in error && error.code === "ENOENT"
|
|
29
|
+
|
|
30
|
+
const contains = (root: string, target: string): boolean => {
|
|
31
|
+
const path = relative(root, target)
|
|
32
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const globRegex = (glob: string): RegExp => {
|
|
36
|
+
let source = ""
|
|
37
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
38
|
+
const character = glob.charAt(index)
|
|
39
|
+
if (character === "*" && glob.charAt(index + 1) === "*") {
|
|
40
|
+
source += ".*"
|
|
41
|
+
index += 1
|
|
42
|
+
} else if (character === "*") {
|
|
43
|
+
source += "[^/]*"
|
|
44
|
+
} else if (character === "?") {
|
|
45
|
+
source += "[^/]"
|
|
46
|
+
} else {
|
|
47
|
+
source += character.replace(/[\\^$.[\]{}()+|]/g, "\\$&")
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return new RegExp(`^${source}$`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nearestExistingParent = async (path: string): Promise<string> => {
|
|
54
|
+
let current = path
|
|
55
|
+
while (true) {
|
|
56
|
+
try {
|
|
57
|
+
await lstat(current)
|
|
58
|
+
return current
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!isMissing(error)) {
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
const parent = dirname(current)
|
|
64
|
+
if (parent === current) {
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
current = parent
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const makeNodeWorkspace = Effect.fn("@llm4ts/runner/NodeWorkspace.make")(function* (
|
|
73
|
+
configuredRoot: string,
|
|
74
|
+
limits: WorkspaceLimits = defaultWorkspaceLimits
|
|
75
|
+
): Effect.fn.Return<WorkspaceShape, WorkspaceError> {
|
|
76
|
+
const root = yield* Effect.tryPromise({
|
|
77
|
+
try: () => realpath(resolve(configuredRoot)),
|
|
78
|
+
catch: (error) =>
|
|
79
|
+
WorkspaceIoError.make({
|
|
80
|
+
operation: "open workspace",
|
|
81
|
+
path: configuredRoot,
|
|
82
|
+
message: errorMessage(error),
|
|
83
|
+
cause: error
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const resolveSafe = (input: string): Effect.Effect<string, WorkspaceError> =>
|
|
88
|
+
Effect.tryPromise({
|
|
89
|
+
try: async () => {
|
|
90
|
+
const lexical = resolve(root, input)
|
|
91
|
+
if (!contains(root, lexical)) {
|
|
92
|
+
throw WorkspacePathError.make({
|
|
93
|
+
path: input,
|
|
94
|
+
message: "path escapes the configured workspace"
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
const existing = await nearestExistingParent(lexical)
|
|
98
|
+
const physical = await realpath(existing)
|
|
99
|
+
if (!contains(root, physical)) {
|
|
100
|
+
throw WorkspacePathError.make({
|
|
101
|
+
path: input,
|
|
102
|
+
message: "path resolves through a symlink outside the configured workspace"
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return lexical
|
|
106
|
+
},
|
|
107
|
+
catch: (error) =>
|
|
108
|
+
error instanceof WorkspacePathError
|
|
109
|
+
? error
|
|
110
|
+
: WorkspaceIoError.make({
|
|
111
|
+
operation: "resolve",
|
|
112
|
+
path: input,
|
|
113
|
+
message: errorMessage(error),
|
|
114
|
+
cause: error
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const read = Effect.fn("@llm4ts/runner/NodeWorkspace.read")(function* (
|
|
119
|
+
input: string
|
|
120
|
+
): Effect.fn.Return<string, WorkspaceError> {
|
|
121
|
+
const path = yield* resolveSafe(input)
|
|
122
|
+
const info = yield* Effect.tryPromise({
|
|
123
|
+
try: () => stat(path),
|
|
124
|
+
catch: (error) =>
|
|
125
|
+
WorkspaceIoError.make({
|
|
126
|
+
operation: "read",
|
|
127
|
+
path: input,
|
|
128
|
+
message: errorMessage(error),
|
|
129
|
+
cause: error
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
if (info.size > limits.maxReadBytes) {
|
|
133
|
+
return yield* WorkspaceLimitError.make({
|
|
134
|
+
operation: "read bytes",
|
|
135
|
+
limit: limits.maxReadBytes,
|
|
136
|
+
actual: info.size
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return yield* Effect.tryPromise({
|
|
140
|
+
try: () => readFile(path, "utf8"),
|
|
141
|
+
catch: (error) =>
|
|
142
|
+
WorkspaceIoError.make({
|
|
143
|
+
operation: "read",
|
|
144
|
+
path: input,
|
|
145
|
+
message: errorMessage(error),
|
|
146
|
+
cause: error
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const writeBounded = (
|
|
152
|
+
operation: "write" | "append",
|
|
153
|
+
input: string,
|
|
154
|
+
contents: string
|
|
155
|
+
): Effect.Effect<void, WorkspaceError> =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const bytes = Buffer.byteLength(contents)
|
|
158
|
+
if (bytes > limits.maxWriteBytes) {
|
|
159
|
+
return yield* WorkspaceLimitError.make({
|
|
160
|
+
operation: `${operation} bytes`,
|
|
161
|
+
limit: limits.maxWriteBytes,
|
|
162
|
+
actual: bytes
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
const path = yield* resolveSafe(input)
|
|
166
|
+
yield* Effect.tryPromise({
|
|
167
|
+
try: async () => {
|
|
168
|
+
await mkdir(dirname(path), { recursive: true })
|
|
169
|
+
if (operation === "write") {
|
|
170
|
+
await writeFile(path, contents, "utf8")
|
|
171
|
+
} else {
|
|
172
|
+
await appendFile(path, contents, "utf8")
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
catch: (error) =>
|
|
176
|
+
WorkspaceIoError.make({
|
|
177
|
+
operation,
|
|
178
|
+
path: input,
|
|
179
|
+
message: errorMessage(error),
|
|
180
|
+
cause: error
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
|
|
186
|
+
Effect.tryPromise({
|
|
187
|
+
try: async () => {
|
|
188
|
+
const normalizedPattern = pattern.replaceAll("\\", "/")
|
|
189
|
+
const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
|
|
190
|
+
const results: Array<string> = []
|
|
191
|
+
const walk = async (directory: string, depth: number): Promise<void> => {
|
|
192
|
+
if (depth > limits.maxDepth) {
|
|
193
|
+
throw WorkspaceLimitError.make({
|
|
194
|
+
operation: "discovery depth",
|
|
195
|
+
limit: limits.maxDepth,
|
|
196
|
+
actual: depth
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
200
|
+
entries.sort((left, right) => left.name.localeCompare(right.name))
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (entry.isSymbolicLink()) {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
const absolute = resolve(directory, entry.name)
|
|
206
|
+
const path = relative(root, absolute).split(sep).join("/")
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
await walk(absolute, depth + 1)
|
|
209
|
+
} else if (entry.isFile() && matcher.test(path)) {
|
|
210
|
+
results.push(path)
|
|
211
|
+
if (results.length > limits.maxResults) {
|
|
212
|
+
throw WorkspaceLimitError.make({
|
|
213
|
+
operation: "discovery results",
|
|
214
|
+
limit: limits.maxResults,
|
|
215
|
+
actual: results.length
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await walk(root, 0)
|
|
222
|
+
return results
|
|
223
|
+
},
|
|
224
|
+
catch: (error) =>
|
|
225
|
+
error instanceof WorkspaceLimitError
|
|
226
|
+
? error
|
|
227
|
+
: WorkspaceIoError.make({
|
|
228
|
+
operation: "discover",
|
|
229
|
+
path: root,
|
|
230
|
+
message: errorMessage(error),
|
|
231
|
+
cause: error
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const search = Effect.fn("@llm4ts/runner/NodeWorkspace.search")(function* (
|
|
236
|
+
query: string,
|
|
237
|
+
pattern = "**/*"
|
|
238
|
+
): Effect.fn.Return<ReadonlyArray<SearchMatch>, WorkspaceError> {
|
|
239
|
+
const paths = yield* discover(pattern)
|
|
240
|
+
const matches: Array<SearchMatch> = []
|
|
241
|
+
for (const path of paths) {
|
|
242
|
+
const text = yield* read(path)
|
|
243
|
+
const lines = text.split("\n")
|
|
244
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
245
|
+
const line = lines[index] ?? ""
|
|
246
|
+
if (line.includes(query)) {
|
|
247
|
+
matches.push(
|
|
248
|
+
SearchMatch.make({
|
|
249
|
+
path,
|
|
250
|
+
line: index + 1,
|
|
251
|
+
text: line
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
if (matches.length > limits.maxResults) {
|
|
255
|
+
return yield* WorkspaceLimitError.make({
|
|
256
|
+
operation: "search results",
|
|
257
|
+
limit: limits.maxResults,
|
|
258
|
+
actual: matches.length
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return matches
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
root,
|
|
269
|
+
resolve: resolveSafe,
|
|
270
|
+
read,
|
|
271
|
+
write: (path, contents) => writeBounded("write", path, contents),
|
|
272
|
+
append: (path, contents) => writeBounded("append", path, contents),
|
|
273
|
+
discover,
|
|
274
|
+
search
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
export const NodeWorkspaceLive = (
|
|
279
|
+
root: string,
|
|
280
|
+
limits: WorkspaceLimits = defaultWorkspaceLimits
|
|
281
|
+
): Layer.Layer<Workspace, WorkspaceError> =>
|
|
282
|
+
Layer.effect(Workspace, makeNodeWorkspace(root, limits))
|
package/src/Package.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const packageName = "@llm4ts/runner"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
export const reviewFingerprint = (...parts: ReadonlyArray<string>): string => {
|
|
4
|
+
const digest = createHash("sha256")
|
|
5
|
+
for (const part of parts) {
|
|
6
|
+
const bytes = Buffer.from(part, "utf8")
|
|
7
|
+
const length = Buffer.alloc(4)
|
|
8
|
+
length.writeUInt32BE(bytes.length)
|
|
9
|
+
digest.update(length)
|
|
10
|
+
digest.update(bytes)
|
|
11
|
+
}
|
|
12
|
+
return digest.digest("hex")
|
|
13
|
+
}
|
package/src/Terminal.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type * as Duration from "effect/Duration"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Ref from "effect/Ref"
|
|
4
|
+
import * as Semaphore from "effect/Semaphore"
|
|
5
|
+
import type * as Scope from "effect/Scope"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import * as Stream from "effect/Stream"
|
|
8
|
+
import type { FlowEvent, FlowEventHub } from "@llm4ts/flow/FlowEvents"
|
|
9
|
+
|
|
10
|
+
export const Verbosity = Schema.Literals(["Quiet", "Normal", "Verbose", "Debug"])
|
|
11
|
+
export type Verbosity = typeof Verbosity.Type
|
|
12
|
+
|
|
13
|
+
export const parseVerbosity = (value: string | undefined): Verbosity => {
|
|
14
|
+
switch (value?.trim().toLowerCase()) {
|
|
15
|
+
case "quiet":
|
|
16
|
+
return "Quiet"
|
|
17
|
+
case "verbose":
|
|
18
|
+
return "Verbose"
|
|
19
|
+
case "debug":
|
|
20
|
+
return "Debug"
|
|
21
|
+
default:
|
|
22
|
+
return "Normal"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const rendersEvent = (verbosity: Verbosity, event: FlowEvent): boolean => {
|
|
27
|
+
switch (event._tag) {
|
|
28
|
+
case "StageStarted":
|
|
29
|
+
case "StageCompleted":
|
|
30
|
+
case "StageFailed":
|
|
31
|
+
case "Aborted":
|
|
32
|
+
case "CapabilityDenied":
|
|
33
|
+
case "CapabilityUnenforceable":
|
|
34
|
+
return true
|
|
35
|
+
case "TokensUsed":
|
|
36
|
+
return verbosity === "Verbose" || verbosity === "Debug"
|
|
37
|
+
default:
|
|
38
|
+
return verbosity !== "Quiet"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const terminalSafe = (value: string): string => {
|
|
43
|
+
let safe = ""
|
|
44
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
45
|
+
const code = value.charCodeAt(index)
|
|
46
|
+
const escapedCsi = code === 27 && value.charAt(index + 1) === "["
|
|
47
|
+
if (escapedCsi || code === 155) {
|
|
48
|
+
index += escapedCsi ? 2 : 1
|
|
49
|
+
while (index < value.length) {
|
|
50
|
+
const terminator = value.charCodeAt(index)
|
|
51
|
+
if (terminator >= 64 && terminator <= 126) {
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
index += 1
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const escapedOsc = code === 27 && value.charAt(index + 1) === "]"
|
|
59
|
+
if (escapedOsc || code === 157) {
|
|
60
|
+
index += escapedOsc ? 2 : 1
|
|
61
|
+
while (index < value.length) {
|
|
62
|
+
const current = value.charCodeAt(index)
|
|
63
|
+
if (current === 7) {
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
if (current === 27 && value.charAt(index + 1) === "\\") {
|
|
67
|
+
index += 1
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
index += 1
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const allowedWhitespace = code === 9 || code === 10 || code === 13
|
|
75
|
+
if (allowedWhitespace || (code >= 32 && !(code >= 127 && code <= 159))) {
|
|
76
|
+
safe += value.charAt(index)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return safe
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const Ansi = Object.freeze({
|
|
83
|
+
reset: "\u001b[0m",
|
|
84
|
+
boldMagenta: "\u001b[1;35m",
|
|
85
|
+
green: "\u001b[32m",
|
|
86
|
+
red: "\u001b[31m",
|
|
87
|
+
yellowBold: "\u001b[1;33m",
|
|
88
|
+
darkGray: "\u001b[90m"
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export interface TerminalPalette {
|
|
92
|
+
readonly enabled: boolean
|
|
93
|
+
readonly stageStart: (label: string) => string
|
|
94
|
+
readonly stageDone: (label: string) => string
|
|
95
|
+
readonly fail: (label: string) => string
|
|
96
|
+
readonly info: (label: string) => string
|
|
97
|
+
readonly assistant: (text: string) => string
|
|
98
|
+
readonly toolCall: (tool: string, args: string) => string
|
|
99
|
+
readonly status: (frame: string, label: string) => string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const makeTerminalPalette = (enabled: boolean): TerminalPalette => {
|
|
103
|
+
const paint = (code: string, text: string): string =>
|
|
104
|
+
enabled ? `${code}${text}${Ansi.reset}` : text
|
|
105
|
+
return {
|
|
106
|
+
enabled,
|
|
107
|
+
stageStart: (label) => `${paint(Ansi.boldMagenta, "▶ ")}${label}`,
|
|
108
|
+
stageDone: (label) => `${paint(Ansi.green, "✔ ")}${label}`,
|
|
109
|
+
fail: (label) => `${paint(Ansi.red, "✖ ")}${label}`,
|
|
110
|
+
info: (label) => paint(Ansi.darkGray, `· ${label}`),
|
|
111
|
+
assistant: (text) => `${paint(Ansi.boldMagenta, "● ")}${text}`,
|
|
112
|
+
toolCall: (tool, args) => {
|
|
113
|
+
const head = paint(Ansi.yellowBold, `● ${tool}`)
|
|
114
|
+
return args.length === 0 ? head : `${head} ${paint(Ansi.darkGray, args)}`
|
|
115
|
+
},
|
|
116
|
+
status: (frame, label) => `${paint(Ansi.boldMagenta, frame)} ${label}`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const plainTerminalPalette = makeTerminalPalette(false)
|
|
121
|
+
|
|
122
|
+
export const terminalLine = (
|
|
123
|
+
event: FlowEvent,
|
|
124
|
+
palette: TerminalPalette = plainTerminalPalette
|
|
125
|
+
): string => {
|
|
126
|
+
const safe = terminalSafe
|
|
127
|
+
switch (event._tag) {
|
|
128
|
+
case "StageStarted":
|
|
129
|
+
return palette.stageStart(safe(event.stage))
|
|
130
|
+
case "StageCompleted":
|
|
131
|
+
return palette.stageDone(safe(event.stage))
|
|
132
|
+
case "StageFailed":
|
|
133
|
+
return palette.fail(`${safe(event.stage)} — ${safe(event.message)}`)
|
|
134
|
+
case "Aborted":
|
|
135
|
+
return palette.fail(`aborted: ${safe(event.message)}`)
|
|
136
|
+
case "Info":
|
|
137
|
+
return palette.info(safe(event.message))
|
|
138
|
+
case "ToolUse":
|
|
139
|
+
return palette.toolCall(safe(event.tool), safe(event.args))
|
|
140
|
+
case "AssistantMessage":
|
|
141
|
+
return palette.assistant(safe(event.text).trim())
|
|
142
|
+
case "TokensUsed":
|
|
143
|
+
return palette.info(
|
|
144
|
+
`tokens: ${safe(event.agent)} ${event.usage.prompt} in / ${event.usage.completion} out`
|
|
145
|
+
)
|
|
146
|
+
case "CapabilityUsed":
|
|
147
|
+
return palette.info(`capability ${safe(event.capability)}: ${safe(event.operation)}`)
|
|
148
|
+
case "CapabilityDenied":
|
|
149
|
+
return palette.fail(
|
|
150
|
+
`capability denied: ${safe(event.capability)} for ${safe(event.operation)}`
|
|
151
|
+
)
|
|
152
|
+
case "CapabilityUnenforceable":
|
|
153
|
+
return palette.fail(`capability unenforceable: ${safe(event.detail)}`)
|
|
154
|
+
case "Declassified":
|
|
155
|
+
return palette.info(`declassified: ${safe(event.label)}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const indentBlock = (depth: number, rendered: string): string => {
|
|
160
|
+
const padding = " ".repeat(Math.max(0, depth))
|
|
161
|
+
const lines = rendered.split("\n")
|
|
162
|
+
return lines.map((line, index) => `${padding}${index === 0 ? "" : " "}${line}`).join("\n")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const closesChild = (event: FlowEvent): boolean =>
|
|
166
|
+
event._tag === "StageCompleted" || event._tag === "StageFailed" || event._tag === "Aborted"
|
|
167
|
+
|
|
168
|
+
export const indentDepths = (events: ReadonlyArray<FlowEvent>): ReadonlyArray<number> => {
|
|
169
|
+
let depth = 0
|
|
170
|
+
return events.map((event) => {
|
|
171
|
+
if (closesChild(event)) {
|
|
172
|
+
depth = Math.max(0, depth - 1)
|
|
173
|
+
return depth
|
|
174
|
+
}
|
|
175
|
+
const current = depth
|
|
176
|
+
if (event._tag === "StageStarted") {
|
|
177
|
+
depth += 1
|
|
178
|
+
}
|
|
179
|
+
return current
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface TerminalSurface {
|
|
184
|
+
readonly palette: TerminalPalette
|
|
185
|
+
readonly log: (line: string) => Effect.Effect<void>
|
|
186
|
+
readonly setStatus: (label: string | undefined) => Effect.Effect<void>
|
|
187
|
+
readonly suspend: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const makePlainTerminalSurface = (
|
|
191
|
+
write: (line: string) => void = (line) => process.stdout.write(`${line}\n`)
|
|
192
|
+
): TerminalSurface => ({
|
|
193
|
+
palette: plainTerminalPalette,
|
|
194
|
+
log: (line) => Effect.sync(() => write(line)),
|
|
195
|
+
setStatus: (_label) => Effect.void,
|
|
196
|
+
suspend: (effect) => effect
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const spinnerFrames = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
|
|
200
|
+
const clearLine = "\r\u001b[2K"
|
|
201
|
+
|
|
202
|
+
export interface TerminalOutput {
|
|
203
|
+
readonly isTTY?: boolean
|
|
204
|
+
readonly write: (text: string) => unknown
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const terminalSupportsColor = (
|
|
208
|
+
output: TerminalOutput,
|
|
209
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
210
|
+
): boolean => output.isTTY === true && environment.NO_COLOR === undefined
|
|
211
|
+
|
|
212
|
+
export const makeLiveTerminalSurface = Effect.fn("@llm4ts/runner/Terminal.makeLive")(function* (
|
|
213
|
+
write: (text: string) => void = (text) => {
|
|
214
|
+
process.stdout.write(text)
|
|
215
|
+
},
|
|
216
|
+
palette: TerminalPalette = makeTerminalPalette(true),
|
|
217
|
+
tick: Duration.Input = "100 millis"
|
|
218
|
+
): Effect.fn.Return<TerminalSurface, never, Scope.Scope> {
|
|
219
|
+
const lock = yield* Semaphore.make(1)
|
|
220
|
+
const status = yield* Ref.make<string | undefined>(undefined)
|
|
221
|
+
const frame = yield* Ref.make(0)
|
|
222
|
+
const suspended = yield* Ref.make(false)
|
|
223
|
+
const emit = (text: string): Effect.Effect<void> => Effect.sync(() => write(text))
|
|
224
|
+
const drawStatus = Effect.gen(function* () {
|
|
225
|
+
if (yield* Ref.get(suspended)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const label = yield* Ref.get(status)
|
|
229
|
+
if (label !== undefined) {
|
|
230
|
+
const currentFrame = yield* Ref.get(frame)
|
|
231
|
+
yield* emit(
|
|
232
|
+
`${clearLine}${palette.status(spinnerFrames[currentFrame % spinnerFrames.length], label)}`
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
const surface: TerminalSurface = {
|
|
237
|
+
palette,
|
|
238
|
+
log: (line) =>
|
|
239
|
+
lock.withPermit(
|
|
240
|
+
emit(clearLine).pipe(Effect.andThen(emit(`${line}\n`)), Effect.andThen(drawStatus))
|
|
241
|
+
),
|
|
242
|
+
setStatus: (label) =>
|
|
243
|
+
lock.withPermit(
|
|
244
|
+
Ref.set(status, label).pipe(Effect.andThen(emit(clearLine)), Effect.andThen(drawStatus))
|
|
245
|
+
),
|
|
246
|
+
suspend: (effect) =>
|
|
247
|
+
lock
|
|
248
|
+
.withPermit(Ref.set(suspended, true).pipe(Effect.andThen(emit(clearLine))))
|
|
249
|
+
.pipe(
|
|
250
|
+
Effect.andThen(effect),
|
|
251
|
+
Effect.ensuring(
|
|
252
|
+
lock.withPermit(Ref.set(suspended, false).pipe(Effect.andThen(drawStatus)))
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
yield* Effect.sleep(tick).pipe(
|
|
257
|
+
Effect.andThen(Ref.update(frame, (current) => current + 1)),
|
|
258
|
+
Effect.andThen(lock.withPermit(drawStatus)),
|
|
259
|
+
Effect.forever,
|
|
260
|
+
Effect.forkScoped
|
|
261
|
+
)
|
|
262
|
+
yield* Effect.addFinalizer(() => lock.withPermit(emit(clearLine)))
|
|
263
|
+
return surface
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
export const makeTerminalSurface = (
|
|
267
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
268
|
+
output: TerminalOutput = process.stdout
|
|
269
|
+
): Effect.Effect<TerminalSurface, never, Scope.Scope> => {
|
|
270
|
+
const write = (text: string): void => {
|
|
271
|
+
output.write(text)
|
|
272
|
+
}
|
|
273
|
+
return terminalSupportsColor(output, environment)
|
|
274
|
+
? makeLiveTerminalSurface(write)
|
|
275
|
+
: Effect.succeed(makePlainTerminalSurface((line) => write(`${line}\n`)))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface TerminalConsumer {
|
|
279
|
+
readonly consumed: Ref.Ref<number>
|
|
280
|
+
readonly awaitDrained: (timeout?: Duration.Input) => Effect.Effect<void>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const consumeTerminalEvents = Effect.fn("@llm4ts/runner/Terminal.consume")(function* (
|
|
284
|
+
events: FlowEventHub,
|
|
285
|
+
surface: TerminalSurface,
|
|
286
|
+
verbosity: Verbosity = "Normal"
|
|
287
|
+
): Effect.fn.Return<TerminalConsumer, never, Scope.Scope> {
|
|
288
|
+
const depth = yield* Ref.make(0)
|
|
289
|
+
const stages = yield* Ref.make<ReadonlyArray<string>>([])
|
|
290
|
+
const consumed = yield* Ref.make(0)
|
|
291
|
+
const subscription = yield* events.subscribe
|
|
292
|
+
const palette = surface.palette
|
|
293
|
+
yield* Stream.fromSubscription(subscription).pipe(
|
|
294
|
+
Stream.runForEach((event) =>
|
|
295
|
+
Effect.gen(function* () {
|
|
296
|
+
const currentDepth = closesChild(event)
|
|
297
|
+
? yield* Ref.updateAndGet(depth, (value) => Math.max(0, value - 1))
|
|
298
|
+
: yield* Ref.get(depth)
|
|
299
|
+
if (event._tag === "StageStarted") {
|
|
300
|
+
yield* Ref.update(depth, (value) => value + 1)
|
|
301
|
+
const active = terminalSafe(event.stage)
|
|
302
|
+
yield* Ref.update(stages, (current) => [...current, active])
|
|
303
|
+
yield* surface.setStatus(active)
|
|
304
|
+
} else if (closesChild(event)) {
|
|
305
|
+
const remaining = yield* Ref.updateAndGet(stages, (current) => current.slice(0, -1))
|
|
306
|
+
yield* surface.setStatus(remaining.at(-1))
|
|
307
|
+
}
|
|
308
|
+
if (rendersEvent(verbosity, event)) {
|
|
309
|
+
yield* surface.log(indentBlock(currentDepth, terminalLine(event, palette)))
|
|
310
|
+
}
|
|
311
|
+
yield* Ref.update(consumed, (count) => count + 1)
|
|
312
|
+
})
|
|
313
|
+
),
|
|
314
|
+
Effect.forkScoped
|
|
315
|
+
)
|
|
316
|
+
const awaitDrained = (timeout: Duration.Input = "3 seconds"): Effect.Effect<void> =>
|
|
317
|
+
Effect.gen(function* () {
|
|
318
|
+
const target = yield* events.publishedCount
|
|
319
|
+
const drain: Effect.Effect<void> = Effect.suspend(() =>
|
|
320
|
+
Ref.get(consumed).pipe(
|
|
321
|
+
Effect.flatMap((count) =>
|
|
322
|
+
count >= target ? Effect.void : Effect.yieldNow.pipe(Effect.andThen(drain))
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
yield* Effect.timeoutOption(drain, timeout)
|
|
327
|
+
})
|
|
328
|
+
return { consumed, awaitDrained }
|
|
329
|
+
})
|