@llm4ts/flow 0.7.5 → 0.8.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.
@@ -0,0 +1,413 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Ref from "effect/Ref"
3
+ import * as Schema from "effect/Schema"
4
+ import { Capabilities } from "@llm4ts/core/Capability"
5
+ import type { ProcessExecutorShape, ProcessResult } from "@llm4ts/core/ProcessExecutor"
6
+ import { ColumnNotFound, ProcessError, type FlowError } from "./FlowError.ts"
7
+ import type { FlowEventsShape } from "./FlowEvents.ts"
8
+ import { guarded } from "./CapabilityGuard.ts"
9
+
10
+ // One tool instance operates one board. `cardTable` is only needed when the
11
+ // project has more than one card table (the CLI resolves the single one).
12
+ export class BasecampProjectRef extends Schema.Class<BasecampProjectRef>("BasecampProjectRef")({
13
+ project: Schema.String,
14
+ cardTable: Schema.optionalKey(Schema.String)
15
+ }) {}
16
+
17
+ export class Column extends Schema.Class<Column>("Column")({
18
+ id: Schema.Int,
19
+ title: Schema.String
20
+ }) {}
21
+
22
+ // Basecamp bodies are rich-text HTML, never Markdown — the field names say so.
23
+ export class Card extends Schema.Class<Card>("Card")({
24
+ id: Schema.Int,
25
+ title: Schema.String,
26
+ contentHtml: Schema.String,
27
+ column: Column,
28
+ assignees: Schema.Array(Schema.String),
29
+ commentsCount: Schema.Int,
30
+ updatedAt: Schema.String,
31
+ url: Schema.String
32
+ }) {}
33
+
34
+ export class CardComment extends Schema.Class<CardComment>("CardComment")({
35
+ id: Schema.Int,
36
+ author: Schema.String,
37
+ contentHtml: Schema.String,
38
+ createdAt: Schema.String
39
+ }) {}
40
+
41
+ export class CardStep extends Schema.Class<CardStep>("CardStep")({
42
+ id: Schema.Int,
43
+ title: Schema.String,
44
+ completed: Schema.Boolean
45
+ }) {}
46
+
47
+ const projectFlags = (board: BasecampProjectRef): ReadonlyArray<string> => [
48
+ "--project",
49
+ board.project,
50
+ ...(board.cardTable === undefined ? [] : ["--card-table", board.cardTable])
51
+ ]
52
+
53
+ const jsonFlags = ["--json", "--quiet"]
54
+
55
+ export const cardColumnsArgs = (board: BasecampProjectRef): ReadonlyArray<string> => [
56
+ "cards",
57
+ "columns",
58
+ ...projectFlags(board),
59
+ ...jsonFlags
60
+ ]
61
+
62
+ export const cardListArgs = (board: BasecampProjectRef, column: Column): ReadonlyArray<string> => [
63
+ "cards",
64
+ "list",
65
+ "--column",
66
+ String(column.id),
67
+ "--all",
68
+ ...projectFlags(board),
69
+ ...jsonFlags
70
+ ]
71
+
72
+ export const cardShowArgs = (board: BasecampProjectRef, cardId: number): ReadonlyArray<string> => [
73
+ "cards",
74
+ "show",
75
+ String(cardId),
76
+ ...projectFlags(board),
77
+ ...jsonFlags
78
+ ]
79
+
80
+ export const cardMoveArgs = (
81
+ board: BasecampProjectRef,
82
+ cardId: number,
83
+ column: Column
84
+ ): ReadonlyArray<string> => [
85
+ "cards",
86
+ "move",
87
+ String(cardId),
88
+ "--to",
89
+ String(column.id),
90
+ ...projectFlags(board),
91
+ "--quiet"
92
+ ]
93
+
94
+ export const cardCreateArgs = (
95
+ board: BasecampProjectRef,
96
+ column: Column,
97
+ title: string,
98
+ content: string,
99
+ assignee?: string
100
+ ): ReadonlyArray<string> => [
101
+ "cards",
102
+ "create",
103
+ title,
104
+ content,
105
+ "--column",
106
+ String(column.id),
107
+ ...(assignee === undefined ? [] : ["--assignee", assignee]),
108
+ ...projectFlags(board),
109
+ ...jsonFlags
110
+ ]
111
+
112
+ export const cardAssignArgs = (
113
+ board: BasecampProjectRef,
114
+ cardId: number,
115
+ assignee: string
116
+ ): ReadonlyArray<string> => [
117
+ "cards",
118
+ "update",
119
+ String(cardId),
120
+ "--assignee",
121
+ assignee,
122
+ ...projectFlags(board),
123
+ "--quiet"
124
+ ]
125
+
126
+ // Comments hang off the card alone; `basecamp comments` rejects --project.
127
+ export const cardCommentsArgs = (cardId: number): ReadonlyArray<string> => [
128
+ "comments",
129
+ "list",
130
+ String(cardId),
131
+ "--all",
132
+ ...jsonFlags
133
+ ]
134
+
135
+ export const cardCommentCreateArgs = (cardId: number, body: string): ReadonlyArray<string> => [
136
+ "comments",
137
+ "create",
138
+ String(cardId),
139
+ body,
140
+ "--quiet"
141
+ ]
142
+
143
+ export const cardStepsArgs = (board: BasecampProjectRef, cardId: number): ReadonlyArray<string> => [
144
+ "cards",
145
+ "steps",
146
+ String(cardId),
147
+ ...projectFlags(board),
148
+ ...jsonFlags
149
+ ]
150
+
151
+ export const cardStepCompleteArgs = (
152
+ board: BasecampProjectRef,
153
+ stepId: number
154
+ ): ReadonlyArray<string> => [
155
+ "cards",
156
+ "step",
157
+ "complete",
158
+ String(stepId),
159
+ ...projectFlags(board),
160
+ "--quiet"
161
+ ]
162
+
163
+ const decodeJson = <A, S extends Schema.Codec<A, string>>(
164
+ operation: string,
165
+ schema: S,
166
+ json: string
167
+ ): Effect.Effect<S["Type"], ProcessError> =>
168
+ Schema.decodeUnknownEffect(schema)(json).pipe(
169
+ Effect.mapError((error) =>
170
+ ProcessError.make({
171
+ message: operation,
172
+ detail: String(error)
173
+ })
174
+ )
175
+ )
176
+
177
+ const columnStruct = Schema.Struct({
178
+ id: Schema.Int,
179
+ title: Schema.String
180
+ })
181
+
182
+ export const parseColumns = (json: string): Effect.Effect<ReadonlyArray<Column>, ProcessError> =>
183
+ decodeJson(
184
+ "basecamp parse columns",
185
+ Schema.fromJsonString(Schema.Array(columnStruct)),
186
+ json
187
+ ).pipe(Effect.map((columns) => columns.map((column) => Column.make(column))))
188
+
189
+ const cardStruct = Schema.Struct({
190
+ id: Schema.Int,
191
+ title: Schema.String,
192
+ content: Schema.optionalKey(Schema.NullOr(Schema.String)),
193
+ parent: columnStruct,
194
+ assignees: Schema.optionalKey(
195
+ Schema.NullOr(Schema.Array(Schema.Struct({ name: Schema.String })))
196
+ ),
197
+ comments_count: Schema.optionalKey(Schema.NullOr(Schema.Int)),
198
+ updated_at: Schema.String,
199
+ app_url: Schema.String
200
+ })
201
+
202
+ const toCard = (item: typeof cardStruct.Type): Card =>
203
+ Card.make({
204
+ id: item.id,
205
+ title: item.title,
206
+ contentHtml: item.content ?? "",
207
+ column: Column.make(item.parent),
208
+ assignees: (item.assignees ?? []).map((assignee) => assignee.name),
209
+ commentsCount: item.comments_count ?? 0,
210
+ updatedAt: item.updated_at,
211
+ url: item.app_url
212
+ })
213
+
214
+ export const parseCard = (json: string): Effect.Effect<Card, ProcessError> =>
215
+ decodeJson("basecamp parse card", Schema.fromJsonString(cardStruct), json).pipe(
216
+ Effect.map(toCard)
217
+ )
218
+
219
+ export const parseCards = (json: string): Effect.Effect<ReadonlyArray<Card>, ProcessError> =>
220
+ decodeJson("basecamp parse cards", Schema.fromJsonString(Schema.Array(cardStruct)), json).pipe(
221
+ Effect.map((items) => items.map(toCard))
222
+ )
223
+
224
+ const commentStruct = Schema.Struct({
225
+ id: Schema.Int,
226
+ content: Schema.String,
227
+ creator: Schema.Struct({ name: Schema.String }),
228
+ created_at: Schema.String
229
+ })
230
+
231
+ export const parseCardComments = (
232
+ json: string
233
+ ): Effect.Effect<ReadonlyArray<CardComment>, ProcessError> =>
234
+ decodeJson(
235
+ "basecamp parse comments",
236
+ Schema.fromJsonString(Schema.Array(commentStruct)),
237
+ json
238
+ ).pipe(
239
+ Effect.map((comments) =>
240
+ comments.map((comment) =>
241
+ CardComment.make({
242
+ id: comment.id,
243
+ author: comment.creator.name,
244
+ contentHtml: comment.content,
245
+ createdAt: comment.created_at
246
+ })
247
+ )
248
+ )
249
+ )
250
+
251
+ const stepStruct = Schema.Struct({
252
+ id: Schema.Int,
253
+ title: Schema.String,
254
+ completed: Schema.Boolean
255
+ })
256
+
257
+ // A card without steps prints `null`, not `[]`.
258
+ export const parseCardSteps = (
259
+ json: string
260
+ ): Effect.Effect<ReadonlyArray<CardStep>, ProcessError> =>
261
+ decodeJson(
262
+ "basecamp parse steps",
263
+ Schema.fromJsonString(Schema.NullOr(Schema.Array(stepStruct))),
264
+ json
265
+ ).pipe(Effect.map((steps) => (steps ?? []).map((step) => CardStep.make(step))))
266
+
267
+ export interface BasecampToolShape {
268
+ readonly listColumns: Effect.Effect<ReadonlyArray<Column>, FlowError>
269
+ readonly resolveColumn: (title: string) => Effect.Effect<Column, FlowError>
270
+ readonly listCards: (column: Column) => Effect.Effect<ReadonlyArray<Card>, FlowError>
271
+ readonly readCard: (cardId: number) => Effect.Effect<Card, FlowError>
272
+ readonly moveCard: (cardId: number, column: Column) => Effect.Effect<void, FlowError>
273
+ readonly createCard: (
274
+ column: Column,
275
+ title: string,
276
+ content: string,
277
+ assignee?: string
278
+ ) => Effect.Effect<Card, FlowError>
279
+ readonly assignCard: (cardId: number, assignee: string) => Effect.Effect<void, FlowError>
280
+ readonly readCardComments: (
281
+ cardId: number
282
+ ) => Effect.Effect<ReadonlyArray<CardComment>, FlowError>
283
+ readonly writeCardComment: (cardId: number, body: string) => Effect.Effect<void, FlowError>
284
+ readonly listSteps: (cardId: number) => Effect.Effect<ReadonlyArray<CardStep>, FlowError>
285
+ readonly completeStep: (stepId: number) => Effect.Effect<void, FlowError>
286
+ }
287
+
288
+ const output = (result: ProcessResult): string => result.stdout.join("\n").trim()
289
+
290
+ export const makeBasecampTool = Effect.fn("@llm4ts/flow/BasecampTool.make")(function* (
291
+ process: ProcessExecutorShape,
292
+ workDir: string,
293
+ events: FlowEventsShape,
294
+ board: BasecampProjectRef
295
+ ): Effect.fn.Return<BasecampToolShape> {
296
+ const columnsCache = yield* Ref.make<ReadonlyArray<Column> | undefined>(undefined)
297
+
298
+ const run = (args: ReadonlyArray<string>): Effect.Effect<ProcessResult, FlowError> =>
299
+ process.run(["basecamp", ...args], workDir, {}).pipe(
300
+ Effect.mapError((error) =>
301
+ ProcessError.make({
302
+ message: `basecamp ${args.join(" ")}`,
303
+ detail: error.message
304
+ })
305
+ ),
306
+ Effect.flatMap((result) =>
307
+ result.exitCode === 0
308
+ ? Effect.succeed(result)
309
+ : Effect.fail(
310
+ ProcessError.make({
311
+ message: `basecamp ${args.join(" ")}`,
312
+ detail:
313
+ [...result.stdout, ...result.stderr].join("\n").trim() ||
314
+ `exit code ${result.exitCode}`
315
+ })
316
+ )
317
+ )
318
+ )
319
+
320
+ const read = <A>(
321
+ operation: string,
322
+ effect: Effect.Effect<A, FlowError>
323
+ ): Effect.Effect<A, FlowError> => guarded(Capabilities.BasecampRead, operation, events, effect)
324
+ const write = <A>(
325
+ operation: string,
326
+ effect: Effect.Effect<A, FlowError>
327
+ ): Effect.Effect<A, FlowError> => guarded(Capabilities.BasecampWrite, operation, events, effect)
328
+
329
+ // The board's columns barely change within a tool's lifetime; fetch once.
330
+ const cachedColumns: Effect.Effect<ReadonlyArray<Column>, FlowError> = Ref.get(columnsCache).pipe(
331
+ Effect.flatMap((cached) =>
332
+ cached !== undefined
333
+ ? Effect.succeed(cached)
334
+ : run(cardColumnsArgs(board)).pipe(
335
+ Effect.flatMap((result) => parseColumns(output(result))),
336
+ Effect.tap((columns) => Ref.set(columnsCache, columns))
337
+ )
338
+ )
339
+ )
340
+
341
+ return {
342
+ listColumns: read("basecamp cards columns", cachedColumns),
343
+ resolveColumn: (title) =>
344
+ read(
345
+ "basecamp cards columns",
346
+ cachedColumns.pipe(
347
+ Effect.flatMap((columns) => {
348
+ const match = columns.find(
349
+ (column) => column.title.toLowerCase() === title.toLowerCase()
350
+ )
351
+ return match !== undefined
352
+ ? Effect.succeed(match)
353
+ : Effect.fail(
354
+ ColumnNotFound.make({
355
+ title,
356
+ available: columns.map((column) => column.title)
357
+ })
358
+ )
359
+ })
360
+ )
361
+ ),
362
+ listCards: (column) =>
363
+ read(
364
+ "basecamp cards list",
365
+ run(cardListArgs(board, column)).pipe(
366
+ Effect.flatMap((result) => parseCards(output(result)))
367
+ )
368
+ ),
369
+ readCard: (cardId) =>
370
+ read(
371
+ "basecamp cards show",
372
+ run(cardShowArgs(board, cardId)).pipe(Effect.flatMap((result) => parseCard(output(result))))
373
+ ),
374
+ moveCard: (cardId, column) =>
375
+ write("basecamp cards move", run(cardMoveArgs(board, cardId, column)).pipe(Effect.asVoid)),
376
+ createCard: (column, title, content, assignee) =>
377
+ write(
378
+ "basecamp cards create",
379
+ run(cardCreateArgs(board, column, title, content, assignee)).pipe(
380
+ Effect.flatMap((result) => parseCard(output(result)))
381
+ )
382
+ ),
383
+ assignCard: (cardId, assignee) =>
384
+ write(
385
+ "basecamp cards update",
386
+ run(cardAssignArgs(board, cardId, assignee)).pipe(Effect.asVoid)
387
+ ),
388
+ readCardComments: (cardId) =>
389
+ read(
390
+ "basecamp comments list",
391
+ run(cardCommentsArgs(cardId)).pipe(
392
+ Effect.flatMap((result) => parseCardComments(output(result)))
393
+ )
394
+ ),
395
+ writeCardComment: (cardId, body) =>
396
+ write(
397
+ "basecamp comments create",
398
+ run(cardCommentCreateArgs(cardId, body)).pipe(Effect.asVoid)
399
+ ),
400
+ listSteps: (cardId) =>
401
+ read(
402
+ "basecamp cards steps",
403
+ run(cardStepsArgs(board, cardId)).pipe(
404
+ Effect.flatMap((result) => parseCardSteps(output(result)))
405
+ )
406
+ ),
407
+ completeStep: (stepId) =>
408
+ write(
409
+ "basecamp cards step complete",
410
+ run(cardStepCompleteArgs(board, stepId)).pipe(Effect.asVoid)
411
+ )
412
+ }
413
+ })
package/src/FlowError.ts CHANGED
@@ -105,6 +105,15 @@ export class FlowCapabilityDenied extends Schema.TaggedErrorClass<FlowCapability
105
105
  }
106
106
  }
107
107
 
108
+ export class ColumnNotFound extends Schema.TaggedErrorClass<ColumnNotFound>()("ColumnNotFound", {
109
+ title: Schema.String,
110
+ available: Schema.Array(Schema.String)
111
+ }) {
112
+ get message(): string {
113
+ return `column "${this.title}" not found on the card table; available: ${this.available.join(", ")}`
114
+ }
115
+ }
116
+
108
117
  export class BudgetExceeded extends Schema.TaggedErrorClass<BudgetExceeded>()("BudgetExceeded", {
109
118
  metric: Schema.Literals(["tokens", "costUsd"]),
110
119
  limit: Schema.Number,
@@ -126,6 +135,7 @@ export const FlowError = Schema.Union([
126
135
  ProcessError,
127
136
  FlowLlmError,
128
137
  FlowCapabilityDenied,
138
+ ColumnNotFound,
129
139
  BudgetExceeded
130
140
  ])
131
141
  export type FlowError = typeof FlowError.Type
package/src/GitHubTool.ts CHANGED
@@ -153,6 +153,22 @@ export const issueCommentEditArgs = (
153
153
  `body=${body}`
154
154
  ]
155
155
 
156
+ export class IssueComment extends Schema.Class<IssueComment>("IssueComment")({
157
+ author: Schema.String,
158
+ body: Schema.String,
159
+ createdAt: Schema.String
160
+ }) {}
161
+
162
+ export const issueCommentsArgs = (ref: IssueRef): ReadonlyArray<string> => [
163
+ "issue",
164
+ "view",
165
+ String(ref.number),
166
+ "--repo",
167
+ `${ref.owner}/${ref.repo}`,
168
+ "--json",
169
+ "comments"
170
+ ]
171
+
156
172
  export const issueCommentArgs = (ref: IssueRef, body: string): ReadonlyArray<string> => [
157
173
  "issue",
158
174
  "comment",
@@ -317,6 +333,18 @@ class GhIssueSummary extends Schema.Class<GhIssueSummary>("GhIssueSummary")({
317
333
  updatedAt: Schema.String
318
334
  }) {}
319
335
 
336
+ class GhComment extends Schema.Class<GhComment>("GhComment")({
337
+ author: GhAuthor,
338
+ body: Schema.String,
339
+ createdAt: Schema.String
340
+ }) {}
341
+
342
+ class GhComments extends Schema.Class<GhComments>("GhComments")({
343
+ comments: Schema.Array(GhComment).pipe(
344
+ Schema.withConstructorDefault(Effect.succeed(Object.freeze([])))
345
+ )
346
+ }) {}
347
+
320
348
  class GhCheck extends Schema.Class<GhCheck>("GhCheck")({
321
349
  status: Schema.optionalKey(Schema.String),
322
350
  conclusion: Schema.optionalKey(Schema.String),
@@ -372,6 +400,28 @@ export const parseIssueList = (
372
400
  )
373
401
  )
374
402
 
403
+ export const parseIssueComments = (
404
+ json: string
405
+ ): Effect.Effect<ReadonlyArray<IssueComment>, ProcessError> =>
406
+ Schema.decodeUnknownEffect(Schema.fromJsonString(GhComments))(json).pipe(
407
+ Effect.map((parsed) =>
408
+ parsed.comments.map(
409
+ (comment) =>
410
+ new IssueComment({
411
+ author: comment.author.login,
412
+ body: comment.body,
413
+ createdAt: comment.createdAt
414
+ })
415
+ )
416
+ ),
417
+ Effect.mapError((error) =>
418
+ ProcessError.make({
419
+ message: "gh issue view comments",
420
+ detail: `invalid comments JSON: ${String(error)}`
421
+ })
422
+ )
423
+ )
424
+
375
425
  export const outcomeFromChecksJson = (json: string): Effect.Effect<BuildOutcome, ProcessError> =>
376
426
  Schema.decodeUnknownEffect(Schema.fromJsonString(GhChecks))(json).pipe(
377
427
  Effect.map((parsed) => {
@@ -404,6 +454,9 @@ export interface GitHubToolShape {
404
454
  draft?: boolean
405
455
  ) => Effect.Effect<PullRequest, FlowError>
406
456
  readonly readIssue: (ref: IssueRef) => Effect.Effect<Issue, FlowError>
457
+ readonly readIssueComments: (
458
+ ref: IssueRef
459
+ ) => Effect.Effect<ReadonlyArray<IssueComment>, FlowError>
407
460
  // Returns the created comment's reference when the gh output carries it
408
461
  // (undefined otherwise), so callers can edit the comment later — e.g. a
409
462
  // plan checklist kept up to date as tasks complete.
@@ -531,6 +584,13 @@ export const makeGitHubTool = (
531
584
  "gh issue view",
532
585
  run(issueViewArgs(ref)).pipe(Effect.flatMap((result) => parseIssue(output(result))))
533
586
  ),
587
+ readIssueComments: (ref) =>
588
+ read(
589
+ "gh issue view comments",
590
+ run(issueCommentsArgs(ref)).pipe(
591
+ Effect.flatMap((result) => parseIssueComments(output(result)))
592
+ )
593
+ ),
534
594
  writeIssueComment: (ref, body) =>
535
595
  write(
536
596
  "gh issue comment",