@llm4ts/flow 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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/AzureDevOpsTool.d.ts +60 -0
  4. package/dist/AzureDevOpsTool.d.ts.map +1 -0
  5. package/dist/AzureDevOpsTool.js +163 -0
  6. package/dist/AzureDevOpsTool.js.map +1 -0
  7. package/dist/Bench.d.ts +133 -0
  8. package/dist/Bench.d.ts.map +1 -0
  9. package/dist/Bench.js +272 -0
  10. package/dist/Bench.js.map +1 -0
  11. package/dist/BenchReport.d.ts +9 -0
  12. package/dist/BenchReport.d.ts.map +1 -0
  13. package/dist/BenchReport.js +360 -0
  14. package/dist/BenchReport.js.map +1 -0
  15. package/dist/CapabilityGuard.d.ts +6 -0
  16. package/dist/CapabilityGuard.d.ts.map +1 -0
  17. package/dist/CapabilityGuard.js +25 -0
  18. package/dist/CapabilityGuard.js.map +1 -0
  19. package/dist/Chat.d.ts +15 -0
  20. package/dist/Chat.d.ts.map +1 -0
  21. package/dist/Chat.js +36 -0
  22. package/dist/Chat.js.map +1 -0
  23. package/dist/Classified.d.ts +14 -0
  24. package/dist/Classified.d.ts.map +1 -0
  25. package/dist/Classified.js +48 -0
  26. package/dist/Classified.js.map +1 -0
  27. package/dist/CostLedger.d.ts +55 -0
  28. package/dist/CostLedger.d.ts.map +1 -0
  29. package/dist/CostLedger.js +126 -0
  30. package/dist/CostLedger.js.map +1 -0
  31. package/dist/CostTracker.d.ts +13 -0
  32. package/dist/CostTracker.d.ts.map +1 -0
  33. package/dist/CostTracker.js +116 -0
  34. package/dist/CostTracker.js.map +1 -0
  35. package/dist/Equiv.d.ts +163 -0
  36. package/dist/Equiv.d.ts.map +1 -0
  37. package/dist/Equiv.js +256 -0
  38. package/dist/Equiv.js.map +1 -0
  39. package/dist/EquivReport.d.ts +16 -0
  40. package/dist/EquivReport.d.ts.map +1 -0
  41. package/dist/EquivReport.js +77 -0
  42. package/dist/EquivReport.js.map +1 -0
  43. package/dist/Flow.d.ts +25 -0
  44. package/dist/Flow.d.ts.map +1 -0
  45. package/dist/Flow.js +39 -0
  46. package/dist/Flow.js.map +1 -0
  47. package/dist/FlowContext.d.ts +23 -0
  48. package/dist/FlowContext.d.ts.map +1 -0
  49. package/dist/FlowContext.js +4 -0
  50. package/dist/FlowContext.js.map +1 -0
  51. package/dist/FlowError.d.ts +80 -0
  52. package/dist/FlowError.d.ts.map +1 -0
  53. package/dist/FlowError.js +92 -0
  54. package/dist/FlowError.js.map +1 -0
  55. package/dist/FlowEvents.d.ts +101 -0
  56. package/dist/FlowEvents.d.ts.map +1 -0
  57. package/dist/FlowEvents.js +103 -0
  58. package/dist/FlowEvents.js.map +1 -0
  59. package/dist/FlowRecorder.d.ts +26 -0
  60. package/dist/FlowRecorder.d.ts.map +1 -0
  61. package/dist/FlowRecorder.js +74 -0
  62. package/dist/FlowRecorder.js.map +1 -0
  63. package/dist/GitHubTool.d.ts +53 -0
  64. package/dist/GitHubTool.d.ts.map +1 -0
  65. package/dist/GitHubTool.js +191 -0
  66. package/dist/GitHubTool.js.map +1 -0
  67. package/dist/GitTool.d.ts +46 -0
  68. package/dist/GitTool.d.ts.map +1 -0
  69. package/dist/GitTool.js +97 -0
  70. package/dist/GitTool.js.map +1 -0
  71. package/dist/Interaction.d.ts +21 -0
  72. package/dist/Interaction.d.ts.map +1 -0
  73. package/dist/Interaction.js +9 -0
  74. package/dist/Interaction.js.map +1 -0
  75. package/dist/McpServer.d.ts +18 -0
  76. package/dist/McpServer.d.ts.map +1 -0
  77. package/dist/McpServer.js +122 -0
  78. package/dist/McpServer.js.map +1 -0
  79. package/dist/Mermaid.d.ts +7 -0
  80. package/dist/Mermaid.d.ts.map +1 -0
  81. package/dist/Mermaid.js +98 -0
  82. package/dist/Mermaid.js.map +1 -0
  83. package/dist/Pack.d.ts +40 -0
  84. package/dist/Pack.d.ts.map +1 -0
  85. package/dist/Pack.js +141 -0
  86. package/dist/Pack.js.map +1 -0
  87. package/dist/Package.d.ts +2 -0
  88. package/dist/Package.d.ts.map +1 -0
  89. package/dist/Package.js +2 -0
  90. package/dist/Package.js.map +1 -0
  91. package/dist/Persistence.d.ts +32 -0
  92. package/dist/Persistence.d.ts.map +1 -0
  93. package/dist/Persistence.js +85 -0
  94. package/dist/Persistence.js.map +1 -0
  95. package/dist/Plan.d.ts +26 -0
  96. package/dist/Plan.d.ts.map +1 -0
  97. package/dist/Plan.js +101 -0
  98. package/dist/Plan.js.map +1 -0
  99. package/dist/PlanExecution.d.ts +7 -0
  100. package/dist/PlanExecution.d.ts.map +1 -0
  101. package/dist/PlanExecution.js +19 -0
  102. package/dist/PlanExecution.js.map +1 -0
  103. package/dist/Planner.d.ts +32 -0
  104. package/dist/Planner.d.ts.map +1 -0
  105. package/dist/Planner.js +97 -0
  106. package/dist/Planner.js.map +1 -0
  107. package/dist/PrSummary.d.ts +15 -0
  108. package/dist/PrSummary.d.ts.map +1 -0
  109. package/dist/PrSummary.js +29 -0
  110. package/dist/PrSummary.js.map +1 -0
  111. package/dist/PriceList.d.ts +11 -0
  112. package/dist/PriceList.d.ts.map +1 -0
  113. package/dist/PriceList.js +79 -0
  114. package/dist/PriceList.js.map +1 -0
  115. package/dist/Provenance.d.ts +27 -0
  116. package/dist/Provenance.d.ts.map +1 -0
  117. package/dist/Provenance.js +38 -0
  118. package/dist/Provenance.js.map +1 -0
  119. package/dist/Replay.d.ts +29 -0
  120. package/dist/Replay.d.ts.map +1 -0
  121. package/dist/Replay.js +181 -0
  122. package/dist/Replay.js.map +1 -0
  123. package/dist/Review.d.ts +71 -0
  124. package/dist/Review.d.ts.map +1 -0
  125. package/dist/Review.js +204 -0
  126. package/dist/Review.js.map +1 -0
  127. package/dist/ReviewCache.d.ts +6 -0
  128. package/dist/ReviewCache.d.ts.map +1 -0
  129. package/dist/ReviewCache.js +28 -0
  130. package/dist/ReviewCache.js.map +1 -0
  131. package/dist/Reviewer.d.ts +12 -0
  132. package/dist/Reviewer.d.ts.map +1 -0
  133. package/dist/Reviewer.js +30 -0
  134. package/dist/Reviewer.js.map +1 -0
  135. package/dist/SpecChecks.d.ts +17 -0
  136. package/dist/SpecChecks.d.ts.map +1 -0
  137. package/dist/SpecChecks.js +107 -0
  138. package/dist/SpecChecks.js.map +1 -0
  139. package/dist/Survey.d.ts +32 -0
  140. package/dist/Survey.d.ts.map +1 -0
  141. package/dist/Survey.js +119 -0
  142. package/dist/Survey.js.map +1 -0
  143. package/dist/TransientRetry.d.ts +16 -0
  144. package/dist/TransientRetry.d.ts.map +1 -0
  145. package/dist/TransientRetry.js +102 -0
  146. package/dist/TransientRetry.js.map +1 -0
  147. package/dist/Workspace.d.ts +39 -0
  148. package/dist/Workspace.d.ts.map +1 -0
  149. package/dist/Workspace.js +192 -0
  150. package/dist/Workspace.js.map +1 -0
  151. package/dist/WorkspaceLayout.d.ts +3 -0
  152. package/dist/WorkspaceLayout.d.ts.map +1 -0
  153. package/dist/WorkspaceLayout.js +19 -0
  154. package/dist/WorkspaceLayout.js.map +1 -0
  155. package/dist/WorkspaceTools.d.ts +4 -0
  156. package/dist/WorkspaceTools.d.ts.map +1 -0
  157. package/dist/WorkspaceTools.js +130 -0
  158. package/dist/WorkspaceTools.js.map +1 -0
  159. package/package.json +91 -0
  160. package/src/AzureDevOpsTool.ts +291 -0
  161. package/src/Bench.ts +340 -0
  162. package/src/BenchReport.ts +447 -0
  163. package/src/CapabilityGuard.ts +43 -0
  164. package/src/Chat.ts +60 -0
  165. package/src/Classified.ts +67 -0
  166. package/src/CostLedger.ts +171 -0
  167. package/src/CostTracker.ts +170 -0
  168. package/src/Equiv.ts +378 -0
  169. package/src/EquivReport.ts +92 -0
  170. package/src/Flow.ts +72 -0
  171. package/src/FlowContext.ts +23 -0
  172. package/src/FlowError.ts +112 -0
  173. package/src/FlowEvents.ts +147 -0
  174. package/src/FlowRecorder.ts +116 -0
  175. package/src/GitHubTool.ts +315 -0
  176. package/src/GitTool.ts +214 -0
  177. package/src/Interaction.ts +23 -0
  178. package/src/McpServer.ts +168 -0
  179. package/src/Mermaid.ts +112 -0
  180. package/src/Pack.ts +213 -0
  181. package/src/Package.ts +1 -0
  182. package/src/Persistence.ts +198 -0
  183. package/src/Plan.ts +119 -0
  184. package/src/PlanExecution.ts +46 -0
  185. package/src/Planner.ts +134 -0
  186. package/src/PrSummary.ts +37 -0
  187. package/src/PriceList.ts +91 -0
  188. package/src/Provenance.ts +85 -0
  189. package/src/Replay.ts +265 -0
  190. package/src/Review.ts +337 -0
  191. package/src/ReviewCache.ts +45 -0
  192. package/src/Reviewer.ts +31 -0
  193. package/src/SpecChecks.ts +143 -0
  194. package/src/Survey.ts +144 -0
  195. package/src/TransientRetry.ts +238 -0
  196. package/src/Workspace.ts +255 -0
  197. package/src/WorkspaceLayout.ts +22 -0
  198. package/src/WorkspaceTools.ts +188 -0
@@ -0,0 +1,238 @@
1
+ import * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Stream from "effect/Stream"
4
+ import type { LlmError } from "@llm4ts/core/Errors"
5
+ import { LlmService, type LlmServiceShape } from "@llm4ts/core/LlmService"
6
+ import type { LlmChunk } from "@llm4ts/core/Models"
7
+ import { FlowEvents, Info } from "./FlowEvents.ts"
8
+
9
+ const transientSignals: ReadonlyArray<string> = [
10
+ "connection reset",
11
+ "server_error",
12
+ "internal error",
13
+ "service unavailable",
14
+ "unavailable",
15
+ "overloaded",
16
+ "temporarily",
17
+ "timed out",
18
+ "api error",
19
+ "unknown error",
20
+ "code=500",
21
+ "code=502",
22
+ "code=503",
23
+ "code=504",
24
+ " 500",
25
+ " 502",
26
+ " 503",
27
+ " 504"
28
+ ]
29
+
30
+ const flakyStreamSignals: ReadonlyArray<string> = [
31
+ "empty response",
32
+ "malformed tool call",
33
+ "invalid stream"
34
+ ]
35
+
36
+ const includesSignal = (message: string, signals: ReadonlyArray<string>): boolean => {
37
+ const normalized = message.toLowerCase()
38
+ return signals.some((signal) => normalized.includes(signal))
39
+ }
40
+
41
+ export const isFlakyStream = (error: LlmError): boolean =>
42
+ error._tag === "ProviderError" && includesSignal(error.message, flakyStreamSignals)
43
+
44
+ export const isTransient = (error: LlmError): boolean => {
45
+ switch (error._tag) {
46
+ case "TimeoutError":
47
+ case "RateLimitError":
48
+ return true
49
+ case "ProviderError":
50
+ return !isFlakyStream(error) && includesSignal(error.message, transientSignals)
51
+ default:
52
+ return false
53
+ }
54
+ }
55
+
56
+ const maxHonoredRetryAfter = Duration.minutes(2)
57
+
58
+ export const transientDelay = (error: LlmError, backoff: Duration.Duration): Duration.Duration => {
59
+ if (error._tag !== "RateLimitError" || error.retryAfter === undefined) {
60
+ return backoff
61
+ }
62
+
63
+ return Duration.max(backoff, Duration.min(error.retryAfter, maxHonoredRetryAfter))
64
+ }
65
+
66
+ export interface TransientRetryOptions {
67
+ readonly maxRetries?: number
68
+ readonly baseDelay?: Duration.Input
69
+ readonly flakyRetries?: number
70
+ readonly flakyDelay?: Duration.Input
71
+ }
72
+
73
+ interface ResolvedTransientRetryOptions {
74
+ readonly maxRetries: number
75
+ readonly baseDelay: Duration.Duration
76
+ readonly flakyRetries: number
77
+ readonly flakyDelay: Duration.Duration
78
+ }
79
+
80
+ const resolveOptions = (options: TransientRetryOptions): ResolvedTransientRetryOptions => ({
81
+ maxRetries: options.maxRetries ?? 3,
82
+ baseDelay: Duration.fromInputUnsafe(options.baseDelay ?? "1 second"),
83
+ flakyRetries: options.flakyRetries ?? 6,
84
+ flakyDelay: Duration.fromInputUnsafe(options.flakyDelay ?? "1 second")
85
+ })
86
+
87
+ const backoff = (baseDelay: Duration.Duration, attempt: number): Duration.Duration =>
88
+ Duration.times(baseDelay, 2 ** attempt)
89
+
90
+ const retryNotice = (
91
+ events: FlowEvents["Service"],
92
+ what: string,
93
+ attempt: number,
94
+ maximum: number,
95
+ error: LlmError
96
+ ): Effect.Effect<void> =>
97
+ events.publish(
98
+ Info.make({
99
+ message: `⟳ ${what} — retry ${attempt + 1}/${maximum}: ${error.message}`
100
+ })
101
+ )
102
+
103
+ const waitForRetry = (
104
+ events: FlowEvents["Service"],
105
+ what: string,
106
+ attempt: number,
107
+ maximum: number,
108
+ error: LlmError,
109
+ delay: Duration.Duration
110
+ ): Effect.Effect<void> =>
111
+ retryNotice(events, what, attempt, maximum, error).pipe(Effect.andThen(Effect.sleep(delay)))
112
+
113
+ const retryEffect = <A, R>(
114
+ effect: Effect.Effect<A, LlmError, R>,
115
+ what: string,
116
+ options: ResolvedTransientRetryOptions,
117
+ events: FlowEvents["Service"]
118
+ ): Effect.Effect<A, LlmError, R> => {
119
+ const loop = (transientAttempt: number, flakyAttempt: number): Effect.Effect<A, LlmError, R> =>
120
+ Effect.catchIf(
121
+ effect,
122
+ () => true,
123
+ (error) => {
124
+ if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
125
+ return waitForRetry(
126
+ events,
127
+ `flaky ${what} (fresh retry)`,
128
+ flakyAttempt,
129
+ options.flakyRetries,
130
+ error,
131
+ options.flakyDelay
132
+ ).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt, flakyAttempt + 1))))
133
+ }
134
+
135
+ if (isTransient(error) && transientAttempt < options.maxRetries) {
136
+ return waitForRetry(
137
+ events,
138
+ `transient error (${what})`,
139
+ transientAttempt,
140
+ options.maxRetries,
141
+ error,
142
+ transientDelay(error, backoff(options.baseDelay, transientAttempt))
143
+ ).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt + 1, flakyAttempt))))
144
+ }
145
+
146
+ return Effect.fail(error)
147
+ }
148
+ )
149
+
150
+ return loop(0, 0)
151
+ }
152
+
153
+ const retryStream = <R>(
154
+ stream: Stream.Stream<LlmChunk, LlmError, R>,
155
+ what: string,
156
+ options: ResolvedTransientRetryOptions,
157
+ events: FlowEvents["Service"]
158
+ ): Stream.Stream<LlmChunk, LlmError, R> => {
159
+ const waitThenRetry = (
160
+ notice: Effect.Effect<void>,
161
+ next: () => Stream.Stream<LlmChunk, LlmError, R>
162
+ ): Stream.Stream<LlmChunk, LlmError, R> =>
163
+ Stream.concat(Stream.drain(Stream.fromEffect(notice)), Stream.suspend(next))
164
+
165
+ const loop = (
166
+ transientAttempt: number,
167
+ flakyAttempt: number
168
+ ): Stream.Stream<LlmChunk, LlmError, R> =>
169
+ Stream.catchIf(
170
+ stream,
171
+ () => true,
172
+ (error) => {
173
+ if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
174
+ return waitThenRetry(
175
+ waitForRetry(
176
+ events,
177
+ `flaky ${what} (fresh retry)`,
178
+ flakyAttempt,
179
+ options.flakyRetries,
180
+ error,
181
+ options.flakyDelay
182
+ ),
183
+ () => loop(transientAttempt, flakyAttempt + 1)
184
+ )
185
+ }
186
+
187
+ if (isTransient(error) && transientAttempt < options.maxRetries) {
188
+ return waitThenRetry(
189
+ waitForRetry(
190
+ events,
191
+ `transient error (${what})`,
192
+ transientAttempt,
193
+ options.maxRetries,
194
+ error,
195
+ transientDelay(error, backoff(options.baseDelay, transientAttempt))
196
+ ),
197
+ () => loop(transientAttempt + 1, flakyAttempt)
198
+ )
199
+ }
200
+
201
+ return Stream.fail(error)
202
+ }
203
+ )
204
+
205
+ return loop(0, 0)
206
+ }
207
+
208
+ export const makeTransientRetry = Effect.fn("@llm4ts/flow/TransientRetry.make")(function* (
209
+ underlying: LlmServiceShape,
210
+ retryOptions: TransientRetryOptions = {}
211
+ ): Effect.fn.Return<LlmServiceShape, never, FlowEvents> {
212
+ const events = yield* FlowEvents
213
+ const options = resolveOptions(retryOptions)
214
+
215
+ return LlmService.of({
216
+ executeStream: (prompt) =>
217
+ retryStream(underlying.executeStream(prompt), "stream", options, events),
218
+ executeStreamWithHistory: (messages) =>
219
+ retryStream(underlying.executeStreamWithHistory(messages), "stream", options, events),
220
+ executeWithTools: (prompt, tools) =>
221
+ retryEffect(underlying.executeWithTools(prompt, tools), "tools", options, events),
222
+ executeStructured: (prompt, schema, jsonSchema) =>
223
+ retryEffect(
224
+ underlying.executeStructured(prompt, schema, jsonSchema),
225
+ "structured",
226
+ options,
227
+ events
228
+ ),
229
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
230
+ retryEffect(
231
+ underlying.executeStructuredWithUsage(prompt, schema, jsonSchema),
232
+ "structured",
233
+ options,
234
+ events
235
+ ),
236
+ isAvailable: underlying.isAvailable
237
+ })
238
+ })
@@ -0,0 +1,255 @@
1
+ import * as Context from "effect/Context"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Ref from "effect/Ref"
4
+ import * as Schema from "effect/Schema"
5
+ import { WorkspaceIoError, WorkspaceLimitError, WorkspacePathError } from "./FlowError.ts"
6
+
7
+ export type WorkspaceError = WorkspacePathError | WorkspaceLimitError | WorkspaceIoError
8
+
9
+ export class SearchMatch extends Schema.Class<SearchMatch>("SearchMatch")({
10
+ path: Schema.String,
11
+ line: Schema.Int,
12
+ text: Schema.String
13
+ }) {}
14
+
15
+ export interface WorkspaceLimits {
16
+ readonly maxReadBytes: number
17
+ readonly maxWriteBytes: number
18
+ readonly maxResults: number
19
+ readonly maxDepth: number
20
+ }
21
+
22
+ export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
23
+ maxReadBytes: 1_048_576,
24
+ maxWriteBytes: 1_048_576,
25
+ maxResults: 1_000,
26
+ maxDepth: 32
27
+ })
28
+
29
+ export interface WorkspaceShape {
30
+ readonly root: string
31
+ readonly resolve: (path: string) => Effect.Effect<string, WorkspaceError>
32
+ readonly read: (path: string) => Effect.Effect<string, WorkspaceError>
33
+ readonly write: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
34
+ readonly append: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
35
+ readonly discover: (pattern?: string) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
36
+ readonly search: (
37
+ query: string,
38
+ pattern?: string
39
+ ) => Effect.Effect<ReadonlyArray<SearchMatch>, WorkspaceError>
40
+ }
41
+
42
+ export class Workspace extends Context.Service<Workspace, WorkspaceShape>()(
43
+ "@llm4ts/flow/Workspace"
44
+ ) {}
45
+
46
+ const globRegex = (glob: string): RegExp => {
47
+ let source = ""
48
+ for (let index = 0; index < glob.length; index += 1) {
49
+ const character = glob.charAt(index)
50
+ if (character === "*" && glob.charAt(index + 1) === "*") {
51
+ source += ".*"
52
+ index += 1
53
+ } else if (character === "*") {
54
+ source += "[^/]*"
55
+ } else if (character === "?") {
56
+ source += "[^/]"
57
+ } else {
58
+ source += character.replace(/[\\^$.[\]{}()+|]/g, "\\$&")
59
+ }
60
+ }
61
+ return new RegExp(`^${source}$`)
62
+ }
63
+
64
+ const comparePaths = (left: string, right: string): number => {
65
+ const leftSegments = left.split("/")
66
+ const rightSegments = right.split("/")
67
+ const shared = Math.min(leftSegments.length, rightSegments.length)
68
+ for (let index = 0; index < shared; index += 1) {
69
+ const order = (leftSegments[index] ?? "").localeCompare(rightSegments[index] ?? "")
70
+ if (order !== 0) {
71
+ return order
72
+ }
73
+ }
74
+ return leftSegments.length - rightSegments.length
75
+ }
76
+
77
+ const byteLength = (contents: string): number => new TextEncoder().encode(contents).length
78
+
79
+ export interface MemoryWorkspaceOptions {
80
+ readonly root?: string
81
+ readonly limits?: WorkspaceLimits
82
+ readonly initial?: Readonly<Record<string, string>>
83
+ }
84
+
85
+ export const makeMemoryWorkspace = (
86
+ options: MemoryWorkspaceOptions = {}
87
+ ): Effect.Effect<WorkspaceShape> => {
88
+ const root = (options.root ?? "/workspace").replace(/\/+$/, "")
89
+ const limits = options.limits ?? defaultWorkspaceLimits
90
+
91
+ const relativeKey = (input: string): string | undefined => {
92
+ const normalized = input.replaceAll("\\", "/")
93
+ const withoutRoot =
94
+ normalized === root
95
+ ? ""
96
+ : normalized.startsWith(`${root}/`)
97
+ ? normalized.slice(root.length + 1)
98
+ : normalized
99
+ if (withoutRoot.startsWith("/")) {
100
+ return undefined
101
+ }
102
+ const segments: Array<string> = []
103
+ for (const part of withoutRoot.split("/")) {
104
+ if (part === "" || part === ".") {
105
+ continue
106
+ }
107
+ if (part === "..") {
108
+ if (segments.length === 0) {
109
+ return undefined
110
+ }
111
+ segments.pop()
112
+ } else {
113
+ segments.push(part)
114
+ }
115
+ }
116
+ return segments.join("/")
117
+ }
118
+
119
+ const resolveKey = (input: string): Effect.Effect<string, WorkspaceError> => {
120
+ const key = relativeKey(input)
121
+ return key === undefined
122
+ ? Effect.fail(
123
+ WorkspacePathError.make({
124
+ path: input,
125
+ message: "path escapes the configured workspace"
126
+ })
127
+ )
128
+ : Effect.succeed(key)
129
+ }
130
+
131
+ return Ref.make<Readonly<Record<string, string>>>(options.initial ?? {}).pipe(
132
+ Effect.map((state) => {
133
+ const read = (input: string): Effect.Effect<string, WorkspaceError> =>
134
+ Effect.gen(function* () {
135
+ const key = yield* resolveKey(input)
136
+ const files = yield* Ref.get(state)
137
+ const contents = files[key]
138
+ if (contents === undefined) {
139
+ return yield* WorkspaceIoError.make({
140
+ operation: "read",
141
+ path: input,
142
+ message: "file does not exist"
143
+ })
144
+ }
145
+ const bytes = byteLength(contents)
146
+ if (bytes > limits.maxReadBytes) {
147
+ return yield* WorkspaceLimitError.make({
148
+ operation: "read bytes",
149
+ limit: limits.maxReadBytes,
150
+ actual: bytes
151
+ })
152
+ }
153
+ return contents
154
+ })
155
+
156
+ const writeBounded = (
157
+ operation: "write" | "append",
158
+ input: string,
159
+ contents: string
160
+ ): Effect.Effect<void, WorkspaceError> => {
161
+ const bytes = byteLength(contents)
162
+ if (bytes > limits.maxWriteBytes) {
163
+ return Effect.fail(
164
+ WorkspaceLimitError.make({
165
+ operation: `${operation} bytes`,
166
+ limit: limits.maxWriteBytes,
167
+ actual: bytes
168
+ })
169
+ )
170
+ }
171
+ return resolveKey(input).pipe(
172
+ Effect.flatMap((key) =>
173
+ Ref.update(state, (files) => ({
174
+ ...files,
175
+ [key]: operation === "write" ? contents : `${files[key] ?? ""}${contents}`
176
+ }))
177
+ )
178
+ )
179
+ }
180
+
181
+ const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
182
+ Effect.gen(function* () {
183
+ const normalizedPattern = pattern.replaceAll("\\", "/")
184
+ const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
185
+ const files = yield* Ref.get(state)
186
+ const results: Array<string> = []
187
+ for (const path of Object.keys(files).sort(comparePaths)) {
188
+ const depth = path.split("/").length - 1
189
+ if (depth > limits.maxDepth) {
190
+ return yield* WorkspaceLimitError.make({
191
+ operation: "discovery depth",
192
+ limit: limits.maxDepth,
193
+ actual: depth
194
+ })
195
+ }
196
+ if (matcher.test(path)) {
197
+ results.push(path)
198
+ if (results.length > limits.maxResults) {
199
+ return yield* WorkspaceLimitError.make({
200
+ operation: "discovery results",
201
+ limit: limits.maxResults,
202
+ actual: results.length
203
+ })
204
+ }
205
+ }
206
+ }
207
+ return results
208
+ })
209
+
210
+ const search = (
211
+ query: string,
212
+ pattern = "**/*"
213
+ ): Effect.Effect<ReadonlyArray<SearchMatch>, WorkspaceError> =>
214
+ Effect.gen(function* () {
215
+ const paths = yield* discover(pattern)
216
+ const matches: Array<SearchMatch> = []
217
+ for (const path of paths) {
218
+ const text = yield* read(path)
219
+ const lines = text.split("\n")
220
+ for (let index = 0; index < lines.length; index += 1) {
221
+ const line = lines[index] ?? ""
222
+ if (line.includes(query)) {
223
+ matches.push(
224
+ SearchMatch.make({
225
+ path,
226
+ line: index + 1,
227
+ text: line
228
+ })
229
+ )
230
+ if (matches.length > limits.maxResults) {
231
+ return yield* WorkspaceLimitError.make({
232
+ operation: "search results",
233
+ limit: limits.maxResults,
234
+ actual: matches.length
235
+ })
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return matches
241
+ })
242
+
243
+ return {
244
+ root,
245
+ resolve: (input: string) =>
246
+ resolveKey(input).pipe(Effect.map((key) => (key === "" ? root : `${root}/${key}`))),
247
+ read,
248
+ write: (path: string, contents: string) => writeBounded("write", path, contents),
249
+ append: (path: string, contents: string) => writeBounded("append", path, contents),
250
+ discover,
251
+ search
252
+ }
253
+ })
254
+ )
255
+ }
@@ -0,0 +1,22 @@
1
+ import { stableHash } from "./Plan.ts"
2
+
3
+ const normalize = (path: string): string => path.replaceAll("\\", "/").replace(/\/+$/, "")
4
+
5
+ const basename = (path: string): string => {
6
+ const parts = normalize(path).split("/")
7
+ return parts.at(-1) || "repo"
8
+ }
9
+
10
+ export const repoId = (workspace: string, repository: string): string | undefined => {
11
+ const normalizedWorkspace = normalize(workspace)
12
+ const normalizedRepository = normalize(repository)
13
+ return normalizedWorkspace === normalizedRepository
14
+ ? undefined
15
+ : `${basename(normalizedRepository)}-${stableHash(normalizedRepository)}`
16
+ }
17
+
18
+ export const llm4tsDirectory = (workspace: string, repository: string): string => {
19
+ const root = `${normalize(workspace)}/.llm4ts`
20
+ const id = repoId(workspace, repository)
21
+ return id === undefined ? root : `${root}/${id}`
22
+ }
@@ -0,0 +1,188 @@
1
+ import * as Effect from "effect/Effect"
2
+ import { ToolExecutionFailed, makeTool, type Tool } from "@llm4ts/core/tools/Tool"
3
+ import { isJsonRecord } from "@llm4ts/core/providers/CliSupport"
4
+ import type { JsonValue } from "@llm4ts/core/providers/CliSupport"
5
+ import type { WorkspaceShape } from "./Workspace.ts"
6
+
7
+ const parameter = (
8
+ parameters: JsonValue,
9
+ name: string
10
+ ): Effect.Effect<string, ToolExecutionFailed> => {
11
+ if (!isJsonRecord(parameters)) {
12
+ return Effect.fail(
13
+ ToolExecutionFailed.make({
14
+ message: "tool parameters must be an object"
15
+ })
16
+ )
17
+ }
18
+ const value = parameters[name]
19
+ return typeof value === "string"
20
+ ? Effect.succeed(value)
21
+ : Effect.fail(
22
+ ToolExecutionFailed.make({
23
+ message: `parameter '${name}' must be a string`
24
+ })
25
+ )
26
+ }
27
+
28
+ const optionalParameter = (
29
+ parameters: JsonValue,
30
+ name: string
31
+ ): Effect.Effect<string | undefined, ToolExecutionFailed> => {
32
+ if (!isJsonRecord(parameters)) {
33
+ return Effect.fail(
34
+ ToolExecutionFailed.make({
35
+ message: "tool parameters must be an object"
36
+ })
37
+ )
38
+ }
39
+ const value = parameters[name]
40
+ return value === undefined || typeof value === "string"
41
+ ? Effect.succeed(value)
42
+ : Effect.fail(
43
+ ToolExecutionFailed.make({
44
+ message: `parameter '${name}' must be a string when supplied`
45
+ })
46
+ )
47
+ }
48
+
49
+ const mapWorkspaceError = Effect.mapError((error: { readonly message: string }) =>
50
+ ToolExecutionFailed.make({ message: error.message })
51
+ )
52
+
53
+ const pathSchema = {
54
+ type: "object",
55
+ properties: {
56
+ path: { type: "string" }
57
+ },
58
+ required: ["path"],
59
+ additionalProperties: false
60
+ }
61
+
62
+ const contentSchema = {
63
+ type: "object",
64
+ properties: {
65
+ path: { type: "string" },
66
+ contents: { type: "string" }
67
+ },
68
+ required: ["path", "contents"],
69
+ additionalProperties: false
70
+ }
71
+
72
+ export const workspaceTools = (workspace: WorkspaceShape): ReadonlyArray<Tool> => [
73
+ makeTool({
74
+ name: "workspace_read",
75
+ description: "Read a bounded UTF-8 file under the workspace root",
76
+ parameters: pathSchema,
77
+ tags: ["file", "read", "workspace"],
78
+ sandbox: "WorkspaceReadOnly",
79
+ execute: (parameters) =>
80
+ Effect.all({
81
+ path: parameter(parameters, "path")
82
+ }).pipe(
83
+ Effect.flatMap(({ path }) => workspace.read(path)),
84
+ mapWorkspaceError
85
+ )
86
+ }),
87
+ makeTool({
88
+ name: "workspace_write",
89
+ description: "Write bounded UTF-8 content under the workspace root",
90
+ parameters: contentSchema,
91
+ tags: ["file", "write", "workspace"],
92
+ execute: (parameters) =>
93
+ Effect.all({
94
+ path: parameter(parameters, "path"),
95
+ contents: parameter(parameters, "contents")
96
+ }).pipe(
97
+ Effect.flatMap(({ path, contents }) => workspace.write(path, contents)),
98
+ mapWorkspaceError,
99
+ Effect.as({ written: true })
100
+ )
101
+ }),
102
+ makeTool({
103
+ name: "workspace_append",
104
+ description: "Append bounded UTF-8 content under the workspace root",
105
+ parameters: contentSchema,
106
+ tags: ["file", "append", "workspace"],
107
+ execute: (parameters) =>
108
+ Effect.all({
109
+ path: parameter(parameters, "path"),
110
+ contents: parameter(parameters, "contents")
111
+ }).pipe(
112
+ Effect.flatMap(({ path, contents }) => workspace.append(path, contents)),
113
+ mapWorkspaceError,
114
+ Effect.as({ appended: true })
115
+ )
116
+ }),
117
+ makeTool({
118
+ name: "workspace_discover",
119
+ description: "Discover files under the workspace root using a bounded glob",
120
+ parameters: {
121
+ type: "object",
122
+ properties: {
123
+ pattern: { type: "string" }
124
+ },
125
+ additionalProperties: false
126
+ },
127
+ tags: ["file", "glob", "discover", "workspace"],
128
+ sandbox: "WorkspaceReadOnly",
129
+ execute: (parameters) =>
130
+ optionalParameter(parameters, "pattern").pipe(
131
+ Effect.flatMap((pattern) => workspace.discover(pattern)),
132
+ mapWorkspaceError,
133
+ Effect.map((paths) => [...paths])
134
+ )
135
+ }),
136
+ makeTool({
137
+ name: "workspace_search",
138
+ description: "Search bounded workspace files for literal text",
139
+ parameters: {
140
+ type: "object",
141
+ properties: {
142
+ query: { type: "string" },
143
+ pattern: { type: "string" }
144
+ },
145
+ required: ["query"],
146
+ additionalProperties: false
147
+ },
148
+ tags: ["file", "search", "workspace"],
149
+ sandbox: "WorkspaceReadOnly",
150
+ execute: (parameters) =>
151
+ Effect.all({
152
+ query: parameter(parameters, "query"),
153
+ pattern: optionalParameter(parameters, "pattern")
154
+ }).pipe(
155
+ Effect.flatMap(({ pattern, query }) => workspace.search(query, pattern)),
156
+ mapWorkspaceError,
157
+ Effect.map((matches) =>
158
+ matches.map((match) => ({
159
+ path: match.path,
160
+ line: match.line,
161
+ text: match.text
162
+ }))
163
+ )
164
+ )
165
+ }),
166
+ makeTool({
167
+ name: "workspace_validate_json",
168
+ description: "Validate that a bounded workspace file contains JSON",
169
+ parameters: pathSchema,
170
+ tags: ["file", "validate", "json", "workspace"],
171
+ sandbox: "WorkspaceReadOnly",
172
+ execute: (parameters) =>
173
+ parameter(parameters, "path").pipe(
174
+ Effect.flatMap((path) => workspace.read(path)),
175
+ mapWorkspaceError,
176
+ Effect.flatMap((contents) =>
177
+ Effect.try({
178
+ try: () => JSON.parse(contents),
179
+ catch: (error) =>
180
+ ToolExecutionFailed.make({
181
+ message: `invalid JSON: ${String(error)}`
182
+ })
183
+ })
184
+ ),
185
+ Effect.as({ valid: true })
186
+ )
187
+ })
188
+ ]