@llm4ts/flow 2.22.2 → 2.24.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/src/GitTool.ts CHANGED
@@ -40,6 +40,10 @@ export interface GitToolShape {
40
40
  paths: ReadonlyArray<string>,
41
41
  threeDot?: boolean
42
42
  ) => Effect.Effect<string, FlowError>
43
+ /** Tracked files under `paths` (`git ls-files`), relative to the repository. */
44
+ readonly listFiles: (
45
+ paths: ReadonlyArray<string>
46
+ ) => Effect.Effect<ReadonlyArray<string>, FlowError>
43
47
  readonly changedFilesVsBase: (
44
48
  base: string,
45
49
  threeDot?: boolean
@@ -352,6 +356,20 @@ export const makeGitTool = (
352
356
  : runOrFail(["diff", `${base}${threeDot ? "..." : ".."}HEAD`, "--", ...paths])
353
357
  )
354
358
  ),
359
+ listFiles: (paths) =>
360
+ read(
361
+ "git ls-files",
362
+ Effect.suspend(() =>
363
+ paths.length === 0 ? Effect.succeed("") : runOrFail(["ls-files", "--", ...paths])
364
+ )
365
+ ).pipe(
366
+ Effect.map((output) =>
367
+ output
368
+ .split(/\r?\n/)
369
+ .map((line) => line.trim())
370
+ .filter((line) => line.length > 0)
371
+ )
372
+ ),
355
373
  changedFilesVsBase: (base, threeDot = true) =>
356
374
  read(
357
375
  "git changedFilesVsBase",
package/src/Stories.ts CHANGED
@@ -14,6 +14,7 @@ import type * as Scope from "effect/Scope"
14
14
  import * as Semaphore from "effect/Semaphore"
15
15
  import * as Stream from "effect/Stream"
16
16
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
17
+ import { cap } from "./Context.ts"
17
18
  import type { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
18
19
  import type { LlmError } from "@llm4ts/core/Errors"
19
20
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
@@ -294,6 +295,71 @@ export const perimeterRules = (story: Story, where?: StoryWhereabouts): string =
294
295
  "- Everything you provide must be implemented completely: other stories depend on it."
295
296
  ].join("\n")
296
297
 
298
+ /**
299
+ * The gates run after every task (typecheck, lint, tests, perimeter), so a
300
+ * coder that runs them itself spends minutes on work the flow repeats.
301
+ */
302
+ export const gateRules = [
303
+ "After each task the flow runs the project's gates on your worktree (typecheck, lint, the",
304
+ "tests) and hands any failure back to you as a finding. Do not run the full test suite,",
305
+ "typecheck or lint yourself. If you need to check your work, run only the one test file",
306
+ "that covers your change."
307
+ ].join("\n")
308
+
309
+ const defaultContextChars = 40_000
310
+ const fileChars = 8_000
311
+
312
+ /**
313
+ * The code a story starts from, for its coder's system prompt: the shared
314
+ * read-only files it builds on, then the files it owns, each capped, until
315
+ * `budget` runs out; the rest by path only. Reading them up front saves the
316
+ * `ls`/`find`/`cat` round trips the coder would otherwise spend on them.
317
+ */
318
+ export const startingCodeOf = (
319
+ story: Story,
320
+ git: GitToolShape,
321
+ files: PlainFileStoreShape,
322
+ worktree: string,
323
+ budget: number
324
+ ): Effect.Effect<string | undefined, FlowError> =>
325
+ Effect.gen(function* () {
326
+ if (budget <= 0) {
327
+ return undefined
328
+ }
329
+ const shared = yield* git.listFiles(story.sharedReadOnly)
330
+ const owned = yield* git.listFiles(story.owned)
331
+ if (shared.length + owned.length === 0) {
332
+ return undefined
333
+ }
334
+ let left = budget
335
+ const leftOut: Array<string> = []
336
+ const section = (title: string, paths: ReadonlyArray<string>) =>
337
+ Effect.gen(function* () {
338
+ const parts: Array<string> = []
339
+ for (const path of paths) {
340
+ const text = yield* Effect.orElseSucceed(
341
+ files.read(join(worktree, path)),
342
+ () => undefined
343
+ )
344
+ if (text === undefined || left <= 0) {
345
+ leftOut.push(path)
346
+ continue
347
+ }
348
+ const shown = cap(text, Math.min(fileChars, left)).text
349
+ left -= shown.length
350
+ parts.push(`### ${path}\n\`\`\`\n${shown}\n\`\`\``)
351
+ }
352
+ return parts.length === 0 ? [] : [title, ...parts]
353
+ })
354
+ return [
355
+ "## The code you start from",
356
+ "Read this before exploring: it is the current content of the files below.",
357
+ ...(yield* section("Shared, read-only — use these as they are:", shared)),
358
+ ...(yield* section("Yours — the story's owned files so far:", owned)),
359
+ ...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
360
+ ].join("\n\n")
361
+ })
362
+
297
363
  export const storyPrompt = (story: Story): string =>
298
364
  [`Story: ${story.title}`, "", story.description.trim()].join("\n")
299
365
 
@@ -507,6 +573,13 @@ export interface StoriesOptions {
507
573
  * story's plan (coder, review, gates, commit). Default 2: one revision.
508
574
  */
509
575
  readonly judgeRounds?: number
576
+ /**
577
+ * How much of the code a story starts from goes into its coder's system
578
+ * prompt, in characters: the shared read-only files it uses, then its own.
579
+ * Each file read up front is a model round trip the coder does not spend
580
+ * exploring. Default 40 000; 0 leaves it out.
581
+ */
582
+ readonly contextChars?: number
510
583
  /** Extra system context per story (house rules, shared read-only excerpts). */
511
584
  readonly system?: (story: Story) => Effect.Effect<string, FlowError>
512
585
  /** How a story's task plan is produced. Default: the story's own coder plans it. */
@@ -828,6 +901,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
828
901
  const extra = options.system === undefined ? undefined : yield* options.system(story)
829
902
  const system = [
830
903
  perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
904
+ gateRules,
831
905
  extra
832
906
  ]
833
907
  .filter((part): part is string => part !== undefined && part.trim().length > 0)
@@ -995,6 +1069,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
995
1069
  })
996
1070
 
997
1071
  const planTasks = options.planTasks ?? defaultPlanTasks
1072
+ // Taken after catch-up and setup: the code as the first task finds it.
1073
+ const startingCode = yield* startingCodeOf(
1074
+ story,
1075
+ git,
1076
+ files,
1077
+ state.worktree,
1078
+ options.contextChars ?? defaultContextChars
1079
+ )
1080
+ const taskSystem = [system, startingCode]
1081
+ .filter((part): part is string => part !== undefined)
1082
+ .join("\n\n")
998
1083
  const implementTasks = implementPlanFlow(storyContext, {
999
1084
  store: makePlanStore(files),
1000
1085
  planPath: planPath(story),
@@ -1005,7 +1090,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1005
1090
  prompt,
1006
1091
  laneOf(story)
1007
1092
  ),
1008
- system,
1093
+ system: taskSystem,
1009
1094
  chatPerTask: true,
1010
1095
  checkoutBranch: false,
1011
1096
  lint: gates,
package/src/Timing.ts CHANGED
@@ -25,6 +25,10 @@ const TimedRole = Context.Reference<string | undefined>("@llm4ts/flow/Timing/Tim
25
25
  defaultValue: () => undefined
26
26
  })
27
27
 
28
+ /** The role the current call is made for: the caller's, or else `label`. */
29
+ export const roleOr = (label: string): Effect.Effect<string> =>
30
+ Effect.map(TimedRole, (role) => role ?? label)
31
+
28
32
  /** Times the seat calls `effect` makes under `role` instead of the seat's own label. */
29
33
  export const withTimedRole = <A, E, R>(role: string, effect: Effect.Effect<A, E, R>) =>
30
34
  Effect.provideService(effect, TimedRole, role)
@@ -0,0 +1,240 @@
1
+ import * as Clock from "effect/Clock"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Exit from "effect/Exit"
4
+ import * as Ref from "effect/Ref"
5
+ import * as Schema from "effect/Schema"
6
+ import * as Stream from "effect/Stream"
7
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
8
+ import type { LlmChunk, Message } from "@llm4ts/core/Models"
9
+ import { redactText } from "@llm4ts/core/observability/Redaction"
10
+ import { roleOr } from "./Timing.ts"
11
+
12
+ /**
13
+ * What the model was told and what it answered, per seat call — an opt-in
14
+ * record (`llm4ts run --transcript`) that `llm4ts watch` tails. It lives in
15
+ * its own files, never in the trace, so the trace and `llm4ts profile` stay
16
+ * content-free. Secrets are redacted and every field is capped.
17
+ */
18
+
19
+ const Call = Schema.TaggedStruct("Call", {
20
+ at: Schema.Number,
21
+ call: Schema.String,
22
+ role: Schema.String,
23
+ executor: Schema.optionalKey(Schema.String),
24
+ /** The system prompt, on a chat's first call only. */
25
+ system: Schema.optionalKey(Schema.String),
26
+ /** The input: a chat's newest message, or a one-shot prompt. */
27
+ input: Schema.String,
28
+ /** How many messages came before the input (a chat resends them all). */
29
+ earlier: Schema.optionalKey(Schema.Int)
30
+ })
31
+
32
+ const Reply = Schema.TaggedStruct("Reply", {
33
+ at: Schema.Number,
34
+ call: Schema.String,
35
+ text: Schema.String
36
+ })
37
+ const Tool = Schema.TaggedStruct("Tool", {
38
+ at: Schema.Number,
39
+ call: Schema.String,
40
+ tool: Schema.String,
41
+ args: Schema.String
42
+ })
43
+ const ToolResult = Schema.TaggedStruct("ToolResult", {
44
+ at: Schema.Number,
45
+ call: Schema.String,
46
+ output: Schema.String,
47
+ failed: Schema.optionalKey(Schema.Boolean)
48
+ })
49
+ const End = Schema.TaggedStruct("End", {
50
+ at: Schema.Number,
51
+ call: Schema.String,
52
+ ms: Schema.Number,
53
+ failed: Schema.optionalKey(Schema.Boolean)
54
+ })
55
+
56
+ export const TranscriptEntry = Schema.Union([Call, Reply, Tool, ToolResult, End])
57
+ export type TranscriptEntry = typeof TranscriptEntry.Type
58
+
59
+ /** Where entries go: one stream per story lane, and one for the run. */
60
+ export interface TranscriptSink {
61
+ readonly write: (lane: string | undefined, entry: TranscriptEntry) => Effect.Effect<void>
62
+ }
63
+
64
+ export interface MemoryTranscriptSink {
65
+ readonly sink: TranscriptSink
66
+ readonly entries: Effect.Effect<
67
+ ReadonlyArray<{ readonly lane: string | undefined; readonly entry: TranscriptEntry }>
68
+ >
69
+ }
70
+
71
+ export const makeMemoryTranscriptSink: Effect.Effect<MemoryTranscriptSink> = Effect.map(
72
+ Ref.make<ReadonlyArray<{ readonly lane: string | undefined; readonly entry: TranscriptEntry }>>(
73
+ []
74
+ ),
75
+ (entries) => ({
76
+ sink: { write: (lane, entry) => Ref.update(entries, (all) => [...all, { lane, entry }]) },
77
+ entries: Ref.get(entries)
78
+ })
79
+ )
80
+
81
+ const inputChars = 64_000
82
+ const partChars = 4_000
83
+
84
+ const clean = (text: string, limit: number): string => redactText(text, { maxLength: limit })
85
+
86
+ let calls = 0
87
+ const nextCall: Effect.Effect<string> = Effect.sync(() => {
88
+ calls += 1
89
+ return `call-${calls}`
90
+ })
91
+
92
+ export interface TranscriptSeatOptions {
93
+ readonly lane?: string
94
+ /** The seat's role; a caller's `withTimedRole` names the call instead. */
95
+ readonly role: string
96
+ /** Who serves the seat, read at each call (a roster hands the coder over). */
97
+ readonly executor?: Effect.Effect<string | undefined>
98
+ }
99
+
100
+ /** A seat whose every call is written to `sink`, as it happens. */
101
+ export const transcriptSeat = (
102
+ service: LlmServiceShape,
103
+ sink: TranscriptSink,
104
+ options: TranscriptSeatOptions
105
+ ): LlmServiceShape => {
106
+ const write = (entry: TranscriptEntry) => sink.write(options.lane, entry)
107
+
108
+ const opened = (
109
+ input: string,
110
+ extra: { readonly system?: string; readonly earlier?: number }
111
+ ): Effect.Effect<{ readonly call: string; readonly start: number }> =>
112
+ Effect.gen(function* () {
113
+ const call = yield* nextCall
114
+ const start = yield* Clock.currentTimeMillis
115
+ const role = yield* roleOr(options.role)
116
+ const executor = options.executor === undefined ? undefined : yield* options.executor
117
+ yield* write({
118
+ _tag: "Call",
119
+ at: start,
120
+ call,
121
+ role,
122
+ ...(executor === undefined ? {} : { executor }),
123
+ ...(extra.system === undefined ? {} : { system: clean(extra.system, inputChars) }),
124
+ input: clean(input, inputChars),
125
+ ...(extra.earlier === undefined ? {} : { earlier: extra.earlier })
126
+ })
127
+ return { call, start }
128
+ })
129
+
130
+ const closed = (call: string, start: number, failed: boolean): Effect.Effect<void> =>
131
+ Effect.flatMap(Clock.currentTimeMillis, (at) =>
132
+ write({ _tag: "End", at, call, ms: at - start, ...(failed ? { failed } : {}) })
133
+ )
134
+
135
+ const chunkEntry = (call: string, chunk: LlmChunk): Effect.Effect<void> =>
136
+ Effect.flatMap(Clock.currentTimeMillis, (at) => {
137
+ switch (chunk.metadata.event) {
138
+ case "tool_use":
139
+ return write({
140
+ _tag: "Tool",
141
+ at,
142
+ call,
143
+ tool: chunk.metadata.tool_name ?? chunk.metadata.toolName ?? "",
144
+ args: clean(chunk.metadata.tool_input ?? chunk.metadata.toolInput ?? "", partChars)
145
+ })
146
+ case "tool_result": {
147
+ const failed =
148
+ chunk.metadata.tool_failed === "true" || chunk.metadata.tool_status === "error"
149
+ return write({
150
+ _tag: "ToolResult",
151
+ at,
152
+ call,
153
+ output: clean(chunk.metadata.tool_content ?? "", partChars),
154
+ ...(failed ? { failed } : {})
155
+ })
156
+ }
157
+ default:
158
+ return chunk.delta.length === 0
159
+ ? Effect.void
160
+ : write({
161
+ _tag: "Reply",
162
+ at,
163
+ call,
164
+ text: redactText(chunk.delta, { maxLength: inputChars })
165
+ })
166
+ }
167
+ })
168
+
169
+ const recorded = <E>(
170
+ open: Effect.Effect<{ readonly call: string; readonly start: number }>,
171
+ stream: Stream.Stream<LlmChunk, E>
172
+ ): Stream.Stream<LlmChunk, E> =>
173
+ Stream.unwrap(
174
+ Effect.map(Effect.all([open, Ref.make(false)]), ([{ call, start }, failed]) =>
175
+ stream.pipe(
176
+ Stream.tap((chunk) => chunkEntry(call, chunk)),
177
+ Stream.tapError(() => Ref.set(failed, true)),
178
+ Stream.ensuring(Effect.flatMap(Ref.get(failed), (did) => closed(call, start, did)))
179
+ )
180
+ )
181
+ )
182
+
183
+ const once = <A, E, R>(input: string, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
184
+ Effect.flatMap(opened(input, {}), ({ call, start }) =>
185
+ Effect.onExit(effect, (exit) =>
186
+ Effect.andThen(
187
+ Exit.isSuccess(exit)
188
+ ? Effect.flatMap(Clock.currentTimeMillis, (at) =>
189
+ write({
190
+ _tag: "Reply",
191
+ at,
192
+ call,
193
+ text: clean(JSON.stringify(exit.value) ?? "", inputChars)
194
+ })
195
+ )
196
+ : Effect.void,
197
+ closed(call, start, Exit.isFailure(exit))
198
+ )
199
+ )
200
+ )
201
+
202
+ /** A chat's first call is told everything; a later one only what is new. */
203
+ const chatInput = (messages: ReadonlyArray<Message>) => {
204
+ const system = messages.filter((message) => message.role === "System")
205
+ const rest = messages.filter((message) => message.role !== "System")
206
+ const first = !rest.some((message) => message.role === "Assistant")
207
+ const newest = rest.at(-1)?.content ?? ""
208
+ return first
209
+ ? {
210
+ input: rest.map((message) => message.content).join("\n\n"),
211
+ extra:
212
+ system.length === 0
213
+ ? {}
214
+ : { system: system.map((message) => message.content).join("\n\n") }
215
+ }
216
+ : { input: newest, extra: { earlier: rest.length - 1 } }
217
+ }
218
+
219
+ const batched = service.scoreLabelSequence
220
+ return {
221
+ executeStream: (prompt) => recorded(opened(prompt, {}), service.executeStream(prompt)),
222
+ executeStreamWithHistory: (messages) => {
223
+ const { input, extra } = chatInput(messages)
224
+ return recorded(opened(input, extra), service.executeStreamWithHistory(messages))
225
+ },
226
+ executeWithTools: (prompt, tools) => once(prompt, service.executeWithTools(prompt, tools)),
227
+ executeStructured: (prompt, schema, jsonSchema) =>
228
+ once(prompt, service.executeStructured(prompt, schema, jsonSchema)),
229
+ executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
230
+ once(prompt, service.executeStructuredWithUsage(prompt, schema, jsonSchema)),
231
+ scoreLabels: (prompt, labels) => once(prompt, service.scoreLabels(prompt, labels)),
232
+ ...(batched === undefined
233
+ ? {}
234
+ : {
235
+ scoreLabelSequence: (prompt: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
236
+ once(prompt, batched(prompt, labelSets))
237
+ }),
238
+ isAvailable: service.isAvailable
239
+ }
240
+ }