@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
package/src/Planner.ts ADDED
@@ -0,0 +1,134 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
4
+ import type { JsonSchema } from "@llm4ts/core/Models"
5
+ import { collect } from "@llm4ts/core/Streaming"
6
+ import { FlowLlmError } from "./FlowError.ts"
7
+ import { Plan } from "./Plan.ts"
8
+
9
+ export class Proceed extends Schema.Class<Proceed>("Proceed")({
10
+ kind: Schema.Literal("Proceed"),
11
+ value: Plan
12
+ }) {}
13
+
14
+ export class Blocked extends Schema.Class<Blocked>("Blocked")({
15
+ kind: Schema.Literal("Blocked"),
16
+ reason: Schema.String
17
+ }) {}
18
+
19
+ export const Verdict = Schema.Union([Proceed, Blocked])
20
+ export type Verdict = typeof Verdict.Type
21
+
22
+ export const planJsonSchema: JsonSchema = {
23
+ type: "object",
24
+ properties: {
25
+ epicId: { type: "string" },
26
+ tasks: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ title: { type: "string" },
32
+ description: { type: "string" },
33
+ completed: { type: "boolean" }
34
+ },
35
+ required: ["title", "description", "completed"]
36
+ }
37
+ },
38
+ brief: { type: "string" }
39
+ },
40
+ required: ["epicId", "tasks"]
41
+ }
42
+
43
+ const verdictJsonSchema: JsonSchema = {
44
+ type: "object",
45
+ properties: {
46
+ kind: { type: "string", enum: ["Proceed", "Blocked"] },
47
+ value: planJsonSchema,
48
+ reason: { type: "string" }
49
+ },
50
+ required: ["kind"]
51
+ }
52
+
53
+ export const defaultPlanInstructions = [
54
+ "You are a planning assistant. Break the user's request into an ordered list of small,",
55
+ "independently implementable tasks. Each task must be a thin vertical slice with an observable",
56
+ "outcome, described in terms of behavior rather than mechanism. Choose a short kebab-case epicId.",
57
+ 'Respond only with JSON: {"epicId":"kebab-case-id","tasks":',
58
+ '[{"title":"...","description":"...","completed":false}]}'
59
+ ].join("\n")
60
+
61
+ export const planFrom = Effect.fn("@llm4ts/flow/Planner.from")(function* (
62
+ reasoning: LlmServiceShape,
63
+ prompt: string,
64
+ instructions = defaultPlanInstructions
65
+ ): Effect.fn.Return<Plan, FlowLlmError> {
66
+ return yield* reasoning
67
+ .executeStructured(`${instructions}\n\nRequest:\n${prompt}`, Plan, planJsonSchema)
68
+ .pipe(Effect.mapError(FlowLlmError.from))
69
+ })
70
+
71
+ export const reviewPlanInstructions = [
72
+ "Review the draft implementation plan for correctness, completeness, simplicity, and concision.",
73
+ "Keep the same epicId unless it is clearly wrong. Return the complete improved plan, not a patch.",
74
+ 'Respond only with JSON: {"epicId":"kebab-case-id","tasks":',
75
+ '[{"title":"...","description":"...","completed":false}]}'
76
+ ].join("\n")
77
+
78
+ export const reviewPlan = Effect.fn("@llm4ts/flow/Planner.review")(function* (
79
+ reasoning: LlmServiceShape,
80
+ plan: Plan,
81
+ instructions = reviewPlanInstructions
82
+ ): Effect.fn.Return<Plan, FlowLlmError> {
83
+ const improved = yield* reasoning
84
+ .executeStructured(`${instructions}\n\nDraft plan:\n${plan.render}`, Plan, planJsonSchema)
85
+ .pipe(Effect.mapError(FlowLlmError.from))
86
+ return Plan.make({
87
+ ...improved,
88
+ ...(plan.brief === undefined ? {} : { brief: plan.brief })
89
+ })
90
+ })
91
+
92
+ export const briefInstructions = [
93
+ "Write a concise codebase brief for an engineer about to implement this change:",
94
+ "relevant modules and file paths, important APIs and types, conventions, and build/test commands.",
95
+ "Explore the repository as needed. Do not restate the task list. Return plain prose."
96
+ ].join("\n")
97
+
98
+ export const writeBrief = Effect.fn("@llm4ts/flow/Planner.brief")(function* (
99
+ reasoning: LlmServiceShape,
100
+ prompt: string,
101
+ instructions = briefInstructions
102
+ ): Effect.fn.Return<string, FlowLlmError> {
103
+ const response = yield* collect(
104
+ reasoning.executeStream(`${instructions}\n\nChange request:\n${prompt}`)
105
+ ).pipe(Effect.mapError(FlowLlmError.from))
106
+ return response.content.trim()
107
+ })
108
+
109
+ export const briefPlan = Effect.fn("@llm4ts/flow/Planner.briefPlan")(function* (
110
+ reasoning: LlmServiceShape,
111
+ plan: Plan,
112
+ prompt: string,
113
+ instructions = briefInstructions
114
+ ): Effect.fn.Return<Plan, FlowLlmError> {
115
+ const brief = yield* writeBrief(reasoning, prompt, instructions)
116
+ return Plan.make({ ...plan, brief })
117
+ })
118
+
119
+ export const assessThenPlanInstructions = [
120
+ "First decide whether this request is ready to implement.",
121
+ 'Respond only with JSON using exactly one form: {"kind":"Blocked","reason":"why it cannot proceed"}',
122
+ 'or {"kind":"Proceed","value":{"epicId":"kebab-id","tasks":',
123
+ '[{"title":"...","description":"...","completed":false}]}}.'
124
+ ].join("\n")
125
+
126
+ export const assessThenPlan = Effect.fn("@llm4ts/flow/Planner.assessThenPlan")(function* (
127
+ reasoning: LlmServiceShape,
128
+ prompt: string,
129
+ instructions = assessThenPlanInstructions
130
+ ): Effect.fn.Return<Verdict, FlowLlmError> {
131
+ return yield* reasoning
132
+ .executeStructured(`${instructions}\n\nRequest:\n${prompt}`, Verdict, verdictJsonSchema)
133
+ .pipe(Effect.mapError(FlowLlmError.from))
134
+ })
@@ -0,0 +1,37 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
4
+ import type { JsonSchema } from "@llm4ts/core/Models"
5
+ import { FlowLlmError } from "./FlowError.ts"
6
+
7
+ export class PrSummary extends Schema.Class<PrSummary>("PrSummary")({
8
+ title: Schema.String,
9
+ body: Schema.String
10
+ }) {}
11
+
12
+ export const prSummaryJsonSchema: JsonSchema = {
13
+ type: "object",
14
+ properties: {
15
+ title: { type: "string" },
16
+ body: { type: "string" }
17
+ },
18
+ required: ["title", "body"]
19
+ }
20
+
21
+ export const summarisePr = Effect.fn("@llm4ts/flow/PrSummary.summarise")(function* (
22
+ reasoning: LlmServiceShape,
23
+ diff: string,
24
+ context?: string
25
+ ): Effect.fn.Return<PrSummary, FlowLlmError> {
26
+ const contextBlock =
27
+ context === undefined || context.length === 0 ? "" : `\nContext:\n${context}\n`
28
+ const prompt = [
29
+ 'Summarise this change as a pull request. Respond only with JSON: {"title":"...","body":"..."}.',
30
+ contextBlock,
31
+ "Diff:",
32
+ diff
33
+ ].join("\n")
34
+ return yield* reasoning
35
+ .executeStructured(prompt, PrSummary, prSummaryJsonSchema)
36
+ .pipe(Effect.mapError(FlowLlmError.from))
37
+ })
@@ -0,0 +1,91 @@
1
+ import type { TokenUsage } from "@llm4ts/core/Models"
2
+
3
+ export interface ModelRate {
4
+ readonly prefix: string
5
+ readonly inputUsdPerMillion: number
6
+ readonly outputUsdPerMillion: number
7
+ readonly cachedInputMultiplier: number
8
+ }
9
+
10
+ export const PricesAsOf = "2026-07"
11
+
12
+ export const ModelRates: ReadonlyArray<ModelRate> = Object.freeze([
13
+ {
14
+ prefix: "claude-fable-5",
15
+ inputUsdPerMillion: 10,
16
+ outputUsdPerMillion: 50,
17
+ cachedInputMultiplier: 0.1
18
+ },
19
+ {
20
+ prefix: "claude-opus-4",
21
+ inputUsdPerMillion: 15,
22
+ outputUsdPerMillion: 75,
23
+ cachedInputMultiplier: 0.1
24
+ },
25
+ {
26
+ prefix: "claude-sonnet-4",
27
+ inputUsdPerMillion: 3,
28
+ outputUsdPerMillion: 15,
29
+ cachedInputMultiplier: 0.1
30
+ },
31
+ {
32
+ prefix: "claude-haiku-4",
33
+ inputUsdPerMillion: 1,
34
+ outputUsdPerMillion: 5,
35
+ cachedInputMultiplier: 0.1
36
+ },
37
+ {
38
+ prefix: "gemini-2.5-pro",
39
+ inputUsdPerMillion: 1.25,
40
+ outputUsdPerMillion: 10,
41
+ cachedInputMultiplier: 0.1
42
+ },
43
+ {
44
+ prefix: "gemini-2.5-flash",
45
+ inputUsdPerMillion: 0.3,
46
+ outputUsdPerMillion: 2.5,
47
+ cachedInputMultiplier: 0.1
48
+ },
49
+ {
50
+ prefix: "gpt-5.5",
51
+ inputUsdPerMillion: 5,
52
+ outputUsdPerMillion: 30,
53
+ cachedInputMultiplier: 0.1
54
+ },
55
+ {
56
+ prefix: "grok-4.5",
57
+ inputUsdPerMillion: 2,
58
+ outputUsdPerMillion: 6,
59
+ cachedInputMultiplier: 0.1
60
+ },
61
+ {
62
+ prefix: "grok-4.3",
63
+ inputUsdPerMillion: 1.25,
64
+ outputUsdPerMillion: 2.5,
65
+ cachedInputMultiplier: 0.1
66
+ },
67
+ {
68
+ prefix: "grok-4.20",
69
+ inputUsdPerMillion: 1.25,
70
+ outputUsdPerMillion: 2.5,
71
+ cachedInputMultiplier: 0.1
72
+ },
73
+ {
74
+ prefix: "grok-build-0.1",
75
+ inputUsdPerMillion: 1,
76
+ outputUsdPerMillion: 2,
77
+ cachedInputMultiplier: 0.1
78
+ }
79
+ ])
80
+
81
+ export const estimateCostUsd = (model: string, usage: TokenUsage): number | undefined => {
82
+ const rate = ModelRates.find(({ prefix }) => model.startsWith(prefix))
83
+ if (rate === undefined) {
84
+ return undefined
85
+ }
86
+ return (
87
+ (usage.prompt / 1_000_000) * rate.inputUsdPerMillion +
88
+ (usage.completion / 1_000_000) * rate.outputUsdPerMillion +
89
+ ((usage.cached ?? 0) / 1_000_000) * rate.inputUsdPerMillion * rate.cachedInputMultiplier
90
+ )
91
+ }
@@ -0,0 +1,85 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { PersistenceError, PlanParseError, type FlowError } from "./FlowError.ts"
4
+ import type { PlainFileStoreShape } from "./Persistence.ts"
5
+
6
+ export class Provenance extends Schema.Class<Provenance>("Provenance")({
7
+ schema: Schema.Int,
8
+ pack: Schema.String,
9
+ llm4tsVersion: Schema.String,
10
+ createdAt: Schema.String,
11
+ approvedBy: Schema.optionalKey(Schema.String),
12
+ seats: Schema.Record(Schema.String, Schema.String),
13
+ specs: Schema.Record(Schema.String, Schema.String),
14
+ gateVerdicts: Schema.Record(Schema.String, Schema.String),
15
+ equivalenceReport: Schema.optionalKey(Schema.String),
16
+ fixSpecs: Schema.Array(Schema.String)
17
+ }) {}
18
+
19
+ const join = (root: string, path: string): string =>
20
+ `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
21
+
22
+ export interface ProvenanceStoreShape {
23
+ readonly write: (path: string, provenance: Provenance) => Effect.Effect<void, FlowError>
24
+ readonly load: (path: string) => Effect.Effect<Provenance, FlowError>
25
+ readonly extend: (
26
+ path: string,
27
+ update: (current: Provenance) => Provenance
28
+ ) => Effect.Effect<Provenance, FlowError>
29
+ readonly hashFiles: (
30
+ root: string,
31
+ paths: ReadonlyArray<string>
32
+ ) => Effect.Effect<Readonly<Record<string, string>>, PersistenceError>
33
+ }
34
+
35
+ export const makeProvenanceStore = (files: PlainFileStoreShape): ProvenanceStoreShape => {
36
+ const write = (path: string, provenance: Provenance): Effect.Effect<void, FlowError> =>
37
+ Schema.encodeEffect(Schema.fromJsonString(Provenance))(provenance).pipe(
38
+ Effect.mapError((error) =>
39
+ PlanParseError.make({
40
+ message: `failed to encode provenance manifest ${path}: ${String(error)}`
41
+ })
42
+ ),
43
+ Effect.flatMap((contents) => files.writeAtomic(path, `${contents}\n`))
44
+ )
45
+
46
+ const load = (path: string): Effect.Effect<Provenance, FlowError> =>
47
+ Effect.flatMap(
48
+ files.read(path),
49
+ (contents): Effect.Effect<Provenance, FlowError> =>
50
+ contents === undefined
51
+ ? Effect.fail(
52
+ PersistenceError.make({
53
+ message: `provenance manifest does not exist: ${path}`
54
+ })
55
+ )
56
+ : Schema.decodeUnknownEffect(Schema.fromJsonString(Provenance))(contents).pipe(
57
+ Effect.mapError((error) =>
58
+ PlanParseError.make({
59
+ message: `malformed provenance manifest ${path}: ${String(error)}`
60
+ })
61
+ )
62
+ )
63
+ )
64
+
65
+ const extend = (
66
+ path: string,
67
+ update: (current: Provenance) => Provenance
68
+ ): Effect.Effect<Provenance, FlowError> =>
69
+ Effect.flatMap(load(path), (current) => {
70
+ const updated = update(current)
71
+ return write(path, updated).pipe(Effect.as(updated))
72
+ })
73
+
74
+ const hashFiles = (
75
+ root: string,
76
+ paths: ReadonlyArray<string>
77
+ ): Effect.Effect<Readonly<Record<string, string>>, PersistenceError> =>
78
+ Effect.forEach(paths, (path) =>
79
+ files
80
+ .hashSha256(join(root, path))
81
+ .pipe(Effect.map((hash): readonly [path: string, hash: string] => [path, hash]))
82
+ ).pipe(Effect.map(Object.fromEntries))
83
+
84
+ return { write, load, extend, hashFiles }
85
+ }
package/src/Replay.ts ADDED
@@ -0,0 +1,265 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Ref from "effect/Ref"
3
+ import * as Schema from "effect/Schema"
4
+ import * as Stream from "effect/Stream"
5
+ import { InvalidRequestError, ParseError, ProviderError } from "@llm4ts/core/Errors"
6
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
7
+ import { LlmChunk, TokenUsage } from "@llm4ts/core/Models"
8
+ import { collect } from "@llm4ts/core/Streaming"
9
+ import { PlanParseError, UnsupportedSchemaVersion, type FlowError } from "./FlowError.ts"
10
+ import { TraceLine } from "./FlowRecorder.ts"
11
+ import type { PlainFileStoreShape } from "./Persistence.ts"
12
+
13
+ export const CurrentTraceSchema = 1
14
+
15
+ export class ReplaySuccess extends Schema.TaggedClass<ReplaySuccess>()("Success", {
16
+ text: Schema.String,
17
+ usage: Schema.optionalKey(TokenUsage),
18
+ model: Schema.optionalKey(Schema.String)
19
+ }) {}
20
+
21
+ export class ReplayFailure extends Schema.TaggedClass<ReplayFailure>()("Failure", {
22
+ message: Schema.String,
23
+ model: Schema.optionalKey(Schema.String)
24
+ }) {}
25
+
26
+ export const ReplayTurn = Schema.Union([ReplaySuccess, ReplayFailure])
27
+ export type ReplayTurn = typeof ReplayTurn.Type
28
+
29
+ const stringField = (
30
+ fields: Readonly<Record<string, unknown>>,
31
+ key: string
32
+ ): string | undefined => {
33
+ const value = fields[key]
34
+ return typeof value === "string" ? value : undefined
35
+ }
36
+
37
+ const integerField = (
38
+ fields: Readonly<Record<string, unknown>>,
39
+ key: string
40
+ ): number | undefined => {
41
+ const value = stringField(fields, key)
42
+ if (value === undefined || !/^-?\d+$/.test(value)) {
43
+ return undefined
44
+ }
45
+ const parsed = Number(value)
46
+ return Number.isSafeInteger(parsed) ? parsed : undefined
47
+ }
48
+
49
+ const isSafeInteger = (value: unknown): value is number =>
50
+ typeof value === "number" && Number.isSafeInteger(value)
51
+
52
+ const flowEventFields = (line: TraceLine): Readonly<Record<string, unknown>> => {
53
+ const event = line.fields.event
54
+ if (typeof event !== "string") {
55
+ return line.fields
56
+ }
57
+ try {
58
+ const decoded: unknown = JSON.parse(event)
59
+ if (typeof decoded !== "object" || decoded === null) {
60
+ return line.fields
61
+ }
62
+ return Object.fromEntries(
63
+ Object.keys(decoded).map((key): readonly [string, unknown] => [
64
+ key,
65
+ Reflect.get(decoded, key)
66
+ ])
67
+ )
68
+ } catch {
69
+ return line.fields
70
+ }
71
+ }
72
+
73
+ export const segmentReplayTurns = (lines: ReadonlyArray<TraceLine>): ReadonlyArray<ReplayTurn> => {
74
+ let pendingUsage: TokenUsage | undefined
75
+ let pendingModel: string | undefined
76
+ const turns: Array<ReplayTurn> = []
77
+
78
+ for (const line of [...lines].sort((left, right) => left.seq - right.seq)) {
79
+ const fields = flowEventFields(line)
80
+ switch (line.kind) {
81
+ case "TokensUsed": {
82
+ const usageValue =
83
+ typeof fields.usage === "object" && fields.usage !== null
84
+ ? {
85
+ prompt: Reflect.get(fields.usage, "prompt"),
86
+ completion: Reflect.get(fields.usage, "completion"),
87
+ total: Reflect.get(fields.usage, "total"),
88
+ cached: Reflect.get(fields.usage, "cached")
89
+ }
90
+ : fields
91
+ const prompt =
92
+ typeof usageValue.prompt === "number" ? usageValue.prompt : integerField(fields, "prompt")
93
+ const completion =
94
+ typeof usageValue.completion === "number"
95
+ ? usageValue.completion
96
+ : integerField(fields, "completion")
97
+ const total =
98
+ typeof usageValue.total === "number" ? usageValue.total : integerField(fields, "total")
99
+ const cached = typeof usageValue.cached === "number" ? usageValue.cached : undefined
100
+ if (isSafeInteger(prompt) && isSafeInteger(completion) && isSafeInteger(total)) {
101
+ pendingUsage = TokenUsage.make({
102
+ prompt,
103
+ completion,
104
+ total,
105
+ ...(cached === undefined ? {} : { cached })
106
+ })
107
+ }
108
+ pendingModel = stringField(fields, "model") ?? pendingModel
109
+ break
110
+ }
111
+ case "AssistantMessage":
112
+ turns.push(
113
+ ReplaySuccess.make({
114
+ text: stringField(fields, "text") ?? "",
115
+ ...(pendingUsage === undefined ? {} : { usage: pendingUsage }),
116
+ ...(pendingModel === undefined ? {} : { model: pendingModel })
117
+ })
118
+ )
119
+ pendingUsage = undefined
120
+ pendingModel = undefined
121
+ break
122
+ case "StreamError": {
123
+ const model = stringField(fields, "model")
124
+ turns.push(
125
+ ReplayFailure.make({
126
+ message: stringField(fields, "message") ?? "",
127
+ ...(model === undefined ? {} : { model })
128
+ })
129
+ )
130
+ pendingUsage = undefined
131
+ pendingModel = undefined
132
+ break
133
+ }
134
+ }
135
+ }
136
+ return turns
137
+ }
138
+
139
+ const traceCodec = Schema.fromJsonString(TraceLine)
140
+
141
+ export const readTrace = Effect.fn("@llm4ts/flow/Replay.readTrace")(function* (
142
+ files: PlainFileStoreShape,
143
+ path: string
144
+ ): Effect.fn.Return<ReadonlyArray<TraceLine>, FlowError> {
145
+ const contents = yield* files.read(path)
146
+ if (contents === undefined) {
147
+ return yield* PlanParseError.make({
148
+ message: `trace does not exist: ${path}`
149
+ })
150
+ }
151
+ const lines = contents
152
+ .split(/\r?\n/u)
153
+ .map((line) => line.trim())
154
+ .filter((line) => line.length > 0)
155
+ const decoded = yield* Effect.forEach(
156
+ lines,
157
+ (line, index) =>
158
+ Schema.decodeUnknownEffect(traceCodec)(line).pipe(
159
+ Effect.mapError((error) =>
160
+ PlanParseError.make({
161
+ message: `malformed trace at ${path}:${index + 1}: ${String(error)}`
162
+ })
163
+ )
164
+ ),
165
+ { concurrency: 1 }
166
+ )
167
+ for (const line of decoded) {
168
+ const actual = line.schemaVersion ?? 1
169
+ if (actual !== CurrentTraceSchema) {
170
+ return yield* UnsupportedSchemaVersion.make({
171
+ path,
172
+ expected: CurrentTraceSchema,
173
+ actual
174
+ })
175
+ }
176
+ }
177
+ return [...decoded].sort((left, right) => left.seq - right.seq)
178
+ })
179
+
180
+ export const makeReplayConnector = Effect.fn("@llm4ts/flow/Replay.makeConnector")(function* (
181
+ turns: ReadonlyArray<ReplayTurn>
182
+ ): Effect.fn.Return<LlmServiceShape> {
183
+ const cursor = yield* Ref.make(0)
184
+
185
+ const next = Stream.unwrap(
186
+ Ref.getAndUpdate(cursor, (index) => index + 1).pipe(
187
+ Effect.map((index) => {
188
+ const turn = turns[index]
189
+ if (turn === undefined) {
190
+ return Stream.fail(
191
+ ProviderError.make({
192
+ message: `replay trace exhausted at turn ${index}`
193
+ })
194
+ )
195
+ }
196
+ if (turn._tag === "Failure") {
197
+ return Stream.fail(ProviderError.make({ message: turn.message }))
198
+ }
199
+ return Stream.succeed(
200
+ LlmChunk.make({
201
+ delta: turn.text,
202
+ finishReason: "stop",
203
+ ...(turn.usage === undefined ? {} : { usage: turn.usage }),
204
+ metadata: {
205
+ provider: "replay",
206
+ ...(turn.model === undefined ? {} : { model: turn.model })
207
+ }
208
+ })
209
+ )
210
+ })
211
+ )
212
+ )
213
+
214
+ return {
215
+ executeStream: (_prompt) => next,
216
+ executeStreamWithHistory: (_messages) => next,
217
+ executeWithTools: (_prompt, _tools) =>
218
+ Effect.fail(
219
+ InvalidRequestError.make({
220
+ message: "replay does not support tool calling"
221
+ })
222
+ ),
223
+ executeStructured: <A, E, RD, RE>(
224
+ _prompt: string,
225
+ schema: Schema.ConstraintCodec<A, E, RD, RE>
226
+ ) =>
227
+ collect(next).pipe(
228
+ Effect.flatMap((response) =>
229
+ Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(response.content)
230
+ ),
231
+ Effect.mapError((error) =>
232
+ ParseError.make({
233
+ message: `replay structured parse error: ${String(error)}`,
234
+ raw: error instanceof ParseError ? error.raw : ""
235
+ })
236
+ )
237
+ ),
238
+ executeStructuredWithUsage: <A, E, RD, RE>(
239
+ _prompt: string,
240
+ schema: Schema.ConstraintCodec<A, E, RD, RE>
241
+ ) =>
242
+ collect(next).pipe(
243
+ Effect.flatMap((response) =>
244
+ Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(response.content).pipe(
245
+ Effect.map((value) => [value, response.usage, response.metadata.model] as const)
246
+ )
247
+ ),
248
+ Effect.mapError((error) =>
249
+ ParseError.make({
250
+ message: `replay structured parse error: ${String(error)}`,
251
+ raw: ""
252
+ })
253
+ )
254
+ ),
255
+ isAvailable: Effect.succeed(true)
256
+ }
257
+ })
258
+
259
+ export const replayFromTrace = Effect.fn("@llm4ts/flow/Replay.fromTrace")(function* (
260
+ files: PlainFileStoreShape,
261
+ path: string
262
+ ): Effect.fn.Return<LlmServiceShape, FlowError> {
263
+ const lines = yield* readTrace(files, path)
264
+ return yield* makeReplayConnector(segmentReplayTurns(lines))
265
+ })