@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/AzureDevOpsTool.d.ts +60 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -0
- package/dist/AzureDevOpsTool.js +163 -0
- package/dist/AzureDevOpsTool.js.map +1 -0
- package/dist/Bench.d.ts +133 -0
- package/dist/Bench.d.ts.map +1 -0
- package/dist/Bench.js +272 -0
- package/dist/Bench.js.map +1 -0
- package/dist/BenchReport.d.ts +9 -0
- package/dist/BenchReport.d.ts.map +1 -0
- package/dist/BenchReport.js +360 -0
- package/dist/BenchReport.js.map +1 -0
- package/dist/CapabilityGuard.d.ts +6 -0
- package/dist/CapabilityGuard.d.ts.map +1 -0
- package/dist/CapabilityGuard.js +25 -0
- package/dist/CapabilityGuard.js.map +1 -0
- package/dist/Chat.d.ts +15 -0
- package/dist/Chat.d.ts.map +1 -0
- package/dist/Chat.js +36 -0
- package/dist/Chat.js.map +1 -0
- package/dist/Classified.d.ts +14 -0
- package/dist/Classified.d.ts.map +1 -0
- package/dist/Classified.js +48 -0
- package/dist/Classified.js.map +1 -0
- package/dist/CostLedger.d.ts +55 -0
- package/dist/CostLedger.d.ts.map +1 -0
- package/dist/CostLedger.js +126 -0
- package/dist/CostLedger.js.map +1 -0
- package/dist/CostTracker.d.ts +13 -0
- package/dist/CostTracker.d.ts.map +1 -0
- package/dist/CostTracker.js +116 -0
- package/dist/CostTracker.js.map +1 -0
- package/dist/Equiv.d.ts +163 -0
- package/dist/Equiv.d.ts.map +1 -0
- package/dist/Equiv.js +256 -0
- package/dist/Equiv.js.map +1 -0
- package/dist/EquivReport.d.ts +16 -0
- package/dist/EquivReport.d.ts.map +1 -0
- package/dist/EquivReport.js +77 -0
- package/dist/EquivReport.js.map +1 -0
- package/dist/Flow.d.ts +25 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +39 -0
- package/dist/Flow.js.map +1 -0
- package/dist/FlowContext.d.ts +23 -0
- package/dist/FlowContext.d.ts.map +1 -0
- package/dist/FlowContext.js +4 -0
- package/dist/FlowContext.js.map +1 -0
- package/dist/FlowError.d.ts +80 -0
- package/dist/FlowError.d.ts.map +1 -0
- package/dist/FlowError.js +92 -0
- package/dist/FlowError.js.map +1 -0
- package/dist/FlowEvents.d.ts +101 -0
- package/dist/FlowEvents.d.ts.map +1 -0
- package/dist/FlowEvents.js +103 -0
- package/dist/FlowEvents.js.map +1 -0
- package/dist/FlowRecorder.d.ts +26 -0
- package/dist/FlowRecorder.d.ts.map +1 -0
- package/dist/FlowRecorder.js +74 -0
- package/dist/FlowRecorder.js.map +1 -0
- package/dist/GitHubTool.d.ts +53 -0
- package/dist/GitHubTool.d.ts.map +1 -0
- package/dist/GitHubTool.js +191 -0
- package/dist/GitHubTool.js.map +1 -0
- package/dist/GitTool.d.ts +46 -0
- package/dist/GitTool.d.ts.map +1 -0
- package/dist/GitTool.js +97 -0
- package/dist/GitTool.js.map +1 -0
- package/dist/Interaction.d.ts +21 -0
- package/dist/Interaction.d.ts.map +1 -0
- package/dist/Interaction.js +9 -0
- package/dist/Interaction.js.map +1 -0
- package/dist/McpServer.d.ts +18 -0
- package/dist/McpServer.d.ts.map +1 -0
- package/dist/McpServer.js +122 -0
- package/dist/McpServer.js.map +1 -0
- package/dist/Mermaid.d.ts +7 -0
- package/dist/Mermaid.d.ts.map +1 -0
- package/dist/Mermaid.js +98 -0
- package/dist/Mermaid.js.map +1 -0
- package/dist/Pack.d.ts +40 -0
- package/dist/Pack.d.ts.map +1 -0
- package/dist/Pack.js +141 -0
- package/dist/Pack.js.map +1 -0
- package/dist/Package.d.ts +2 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +2 -0
- package/dist/Package.js.map +1 -0
- package/dist/Persistence.d.ts +32 -0
- package/dist/Persistence.d.ts.map +1 -0
- package/dist/Persistence.js +85 -0
- package/dist/Persistence.js.map +1 -0
- package/dist/Plan.d.ts +26 -0
- package/dist/Plan.d.ts.map +1 -0
- package/dist/Plan.js +101 -0
- package/dist/Plan.js.map +1 -0
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -0
- package/dist/PlanExecution.js +19 -0
- package/dist/PlanExecution.js.map +1 -0
- package/dist/Planner.d.ts +32 -0
- package/dist/Planner.d.ts.map +1 -0
- package/dist/Planner.js +97 -0
- package/dist/Planner.js.map +1 -0
- package/dist/PrSummary.d.ts +15 -0
- package/dist/PrSummary.d.ts.map +1 -0
- package/dist/PrSummary.js +29 -0
- package/dist/PrSummary.js.map +1 -0
- package/dist/PriceList.d.ts +11 -0
- package/dist/PriceList.d.ts.map +1 -0
- package/dist/PriceList.js +79 -0
- package/dist/PriceList.js.map +1 -0
- package/dist/Provenance.d.ts +27 -0
- package/dist/Provenance.d.ts.map +1 -0
- package/dist/Provenance.js +38 -0
- package/dist/Provenance.js.map +1 -0
- package/dist/Replay.d.ts +29 -0
- package/dist/Replay.d.ts.map +1 -0
- package/dist/Replay.js +181 -0
- package/dist/Replay.js.map +1 -0
- package/dist/Review.d.ts +71 -0
- package/dist/Review.d.ts.map +1 -0
- package/dist/Review.js +204 -0
- package/dist/Review.js.map +1 -0
- package/dist/ReviewCache.d.ts +6 -0
- package/dist/ReviewCache.d.ts.map +1 -0
- package/dist/ReviewCache.js +28 -0
- package/dist/ReviewCache.js.map +1 -0
- package/dist/Reviewer.d.ts +12 -0
- package/dist/Reviewer.d.ts.map +1 -0
- package/dist/Reviewer.js +30 -0
- package/dist/Reviewer.js.map +1 -0
- package/dist/SpecChecks.d.ts +17 -0
- package/dist/SpecChecks.d.ts.map +1 -0
- package/dist/SpecChecks.js +107 -0
- package/dist/SpecChecks.js.map +1 -0
- package/dist/Survey.d.ts +32 -0
- package/dist/Survey.d.ts.map +1 -0
- package/dist/Survey.js +119 -0
- package/dist/Survey.js.map +1 -0
- package/dist/TransientRetry.d.ts +16 -0
- package/dist/TransientRetry.d.ts.map +1 -0
- package/dist/TransientRetry.js +102 -0
- package/dist/TransientRetry.js.map +1 -0
- package/dist/Workspace.d.ts +39 -0
- package/dist/Workspace.d.ts.map +1 -0
- package/dist/Workspace.js +192 -0
- package/dist/Workspace.js.map +1 -0
- package/dist/WorkspaceLayout.d.ts +3 -0
- package/dist/WorkspaceLayout.d.ts.map +1 -0
- package/dist/WorkspaceLayout.js +19 -0
- package/dist/WorkspaceLayout.js.map +1 -0
- package/dist/WorkspaceTools.d.ts +4 -0
- package/dist/WorkspaceTools.d.ts.map +1 -0
- package/dist/WorkspaceTools.js +130 -0
- package/dist/WorkspaceTools.js.map +1 -0
- package/package.json +91 -0
- package/src/AzureDevOpsTool.ts +291 -0
- package/src/Bench.ts +340 -0
- package/src/BenchReport.ts +447 -0
- package/src/CapabilityGuard.ts +43 -0
- package/src/Chat.ts +60 -0
- package/src/Classified.ts +67 -0
- package/src/CostLedger.ts +171 -0
- package/src/CostTracker.ts +170 -0
- package/src/Equiv.ts +378 -0
- package/src/EquivReport.ts +92 -0
- package/src/Flow.ts +72 -0
- package/src/FlowContext.ts +23 -0
- package/src/FlowError.ts +112 -0
- package/src/FlowEvents.ts +147 -0
- package/src/FlowRecorder.ts +116 -0
- package/src/GitHubTool.ts +315 -0
- package/src/GitTool.ts +214 -0
- package/src/Interaction.ts +23 -0
- package/src/McpServer.ts +168 -0
- package/src/Mermaid.ts +112 -0
- package/src/Pack.ts +213 -0
- package/src/Package.ts +1 -0
- package/src/Persistence.ts +198 -0
- package/src/Plan.ts +119 -0
- package/src/PlanExecution.ts +46 -0
- package/src/Planner.ts +134 -0
- package/src/PrSummary.ts +37 -0
- package/src/PriceList.ts +91 -0
- package/src/Provenance.ts +85 -0
- package/src/Replay.ts +265 -0
- package/src/Review.ts +337 -0
- package/src/ReviewCache.ts +45 -0
- package/src/Reviewer.ts +31 -0
- package/src/SpecChecks.ts +143 -0
- package/src/Survey.ts +144 -0
- package/src/TransientRetry.ts +238 -0
- package/src/Workspace.ts +255 -0
- package/src/WorkspaceLayout.ts +22 -0
- package/src/WorkspaceTools.ts +188 -0
package/src/Pack.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { Dimension } from "@llm4ts/core/eval/Eval"
|
|
4
|
+
import { PlanParseError } from "./FlowError.ts"
|
|
5
|
+
import { parseReviewer, type Reviewer } from "./Reviewer.ts"
|
|
6
|
+
import { CoverageRule } from "./SpecChecks.ts"
|
|
7
|
+
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
8
|
+
|
|
9
|
+
export { Reviewer, parseReviewer } from "./Reviewer.ts"
|
|
10
|
+
|
|
11
|
+
export const ComparisonOrdering = Schema.Literals(["Ordered", "Unordered", "PerKey"])
|
|
12
|
+
export type ComparisonOrdering = typeof ComparisonOrdering.Type
|
|
13
|
+
|
|
14
|
+
export class ComparisonPolicy extends Schema.Class<ComparisonPolicy>("ComparisonPolicy")({
|
|
15
|
+
ordering: ComparisonOrdering,
|
|
16
|
+
ignore: Schema.ReadonlySet(Schema.String)
|
|
17
|
+
}) {}
|
|
18
|
+
|
|
19
|
+
export interface Pack {
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly source: string
|
|
22
|
+
readonly scaffold: string | undefined
|
|
23
|
+
readonly sources: string | undefined
|
|
24
|
+
readonly programs: string | undefined
|
|
25
|
+
readonly specsDir: string
|
|
26
|
+
readonly featuresDir: string
|
|
27
|
+
readonly gates: Readonly<Record<string, ReadonlyArray<string>>>
|
|
28
|
+
readonly replay: ReadonlyArray<string> | undefined
|
|
29
|
+
readonly equivalence: ComparisonPolicy
|
|
30
|
+
readonly judgeDimensions: ReadonlyArray<Dimension>
|
|
31
|
+
readonly coverage: ReadonlyArray<CoverageRule>
|
|
32
|
+
readonly survey: ReadonlyArray<CoverageRule>
|
|
33
|
+
readonly prompts: Readonly<Record<string, string>>
|
|
34
|
+
readonly lenses: ReadonlyArray<Reviewer>
|
|
35
|
+
readonly lessons: string | undefined
|
|
36
|
+
readonly dir: string
|
|
37
|
+
readonly gate: (name: string) => ReadonlyArray<string> | undefined
|
|
38
|
+
readonly prompt: (name: string) => string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ParsedManifest {
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly fields: Readonly<Record<string, string>>
|
|
44
|
+
readonly sections: ReadonlyArray<string>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parseManifest = (markdown: string): Effect.Effect<ParsedManifest, PlanParseError> => {
|
|
48
|
+
const chunks = markdown
|
|
49
|
+
.trim()
|
|
50
|
+
.split(/^(?=## )/m)
|
|
51
|
+
.map((chunk) => chunk.trim())
|
|
52
|
+
.filter((chunk) => chunk.length > 0)
|
|
53
|
+
const head = chunks[0]
|
|
54
|
+
const prefix = "# Pack: "
|
|
55
|
+
if (head === undefined || !head.startsWith(prefix)) {
|
|
56
|
+
return Effect.fail(
|
|
57
|
+
PlanParseError.make({
|
|
58
|
+
message: `expected a '${prefix}<name>' header`
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const lines = head.split(/\r?\n/)
|
|
63
|
+
const fields = Object.fromEntries(
|
|
64
|
+
lines.slice(1).flatMap((line) => {
|
|
65
|
+
const index = line.indexOf(":")
|
|
66
|
+
return index < 0 || line.trim().startsWith("#") || line.trim().startsWith("-")
|
|
67
|
+
? []
|
|
68
|
+
: [[line.slice(0, index).trim(), line.slice(index + 1).trim()]]
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
return fields.source === undefined
|
|
72
|
+
? Effect.fail(
|
|
73
|
+
PlanParseError.make({
|
|
74
|
+
message: "pack manifest is missing a 'source:' field"
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
: Effect.succeed({
|
|
78
|
+
name: lines[0]?.slice(prefix.length).trim() ?? "",
|
|
79
|
+
fields,
|
|
80
|
+
sections: chunks.slice(1)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const section = (sections: ReadonlyArray<string>, title: string): string | undefined =>
|
|
85
|
+
sections
|
|
86
|
+
.find((candidate) => candidate.split(/\r?\n/)[0]?.trim() === `## ${title}`)
|
|
87
|
+
?.split(/\r?\n/)
|
|
88
|
+
.slice(1)
|
|
89
|
+
.join("\n")
|
|
90
|
+
.trim()
|
|
91
|
+
|
|
92
|
+
const namedItems = (body: string | undefined): Readonly<Record<string, string>> =>
|
|
93
|
+
Object.fromEntries(
|
|
94
|
+
(body ?? "").split(/\r?\n/).flatMap((line) => {
|
|
95
|
+
const match = /^- ([^:]+): (.+)$/.exec(line)
|
|
96
|
+
return match?.[1] === undefined || match[2] === undefined
|
|
97
|
+
? []
|
|
98
|
+
: [[match[1].trim(), match[2].trim()]]
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const rules = (sections: ReadonlyArray<string>, prefix: string): ReadonlyArray<CoverageRule> =>
|
|
103
|
+
sections.flatMap((candidate) => {
|
|
104
|
+
const lines = candidate.split(/\r?\n/)
|
|
105
|
+
const heading = lines[0]?.trim() ?? ""
|
|
106
|
+
if (!heading.startsWith(prefix)) {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
const fields = Object.fromEntries(
|
|
110
|
+
lines.slice(1).flatMap((line) => {
|
|
111
|
+
const index = line.indexOf(":")
|
|
112
|
+
return index < 0 ? [] : [[line.slice(0, index).trim(), line.slice(index + 1).trim()]]
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
return fields.files === undefined || fields.unit === undefined
|
|
116
|
+
? []
|
|
117
|
+
: [
|
|
118
|
+
CoverageRule.make({
|
|
119
|
+
name: heading.slice(prefix.length).trim(),
|
|
120
|
+
files: fields.files,
|
|
121
|
+
unit: fields.unit
|
|
122
|
+
})
|
|
123
|
+
]
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const dimensions = (body: string | undefined): ReadonlyArray<Dimension> =>
|
|
127
|
+
(body ?? "").split(/\r?\n/).flatMap((line) => {
|
|
128
|
+
const match = /^- ([^(]+)\(0\.\.(\d+)\): (.+)$/.exec(line)
|
|
129
|
+
const name = match?.[1]?.trim()
|
|
130
|
+
const maxScore = Number.parseInt(match?.[2] ?? "", 10)
|
|
131
|
+
const rubric = match?.[3]?.trim()
|
|
132
|
+
return name === undefined || rubric === undefined || !Number.isInteger(maxScore)
|
|
133
|
+
? []
|
|
134
|
+
: [Dimension.make({ name, rubric, maxScore })]
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const markdownSidecars = Effect.fn("@llm4ts/flow/Pack.markdownSidecars")(function* (
|
|
138
|
+
workspace: WorkspaceShape,
|
|
139
|
+
directory: string
|
|
140
|
+
): Effect.fn.Return<Readonly<Record<string, string>>, WorkspaceError> {
|
|
141
|
+
const paths = yield* workspace.discover(`${directory}/*.md`)
|
|
142
|
+
const entries: Array<readonly [string, string]> = []
|
|
143
|
+
for (const path of paths) {
|
|
144
|
+
const file = path.split("/").at(-1) ?? path
|
|
145
|
+
entries.push([file.replace(/\.md$/, ""), (yield* workspace.read(path)).trim()])
|
|
146
|
+
}
|
|
147
|
+
return Object.fromEntries(entries)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
151
|
+
workspace: WorkspaceShape,
|
|
152
|
+
directory: string
|
|
153
|
+
): Effect.fn.Return<Pack, WorkspaceError | PlanParseError> {
|
|
154
|
+
const manifest = yield* parseManifest(yield* workspace.read(`${directory}/pack.md`))
|
|
155
|
+
const prompts = yield* markdownSidecars(workspace, `${directory}/prompts`)
|
|
156
|
+
const lensFiles = yield* markdownSidecars(workspace, `${directory}/reviewers`)
|
|
157
|
+
const lessons = yield* workspace.read(`${directory}/lessons.md`).pipe(
|
|
158
|
+
Effect.map((text) => text.trim()),
|
|
159
|
+
Effect.catch(() => Effect.succeed(undefined))
|
|
160
|
+
)
|
|
161
|
+
const gateValues = namedItems(section(manifest.sections, "Gates"))
|
|
162
|
+
const equivalenceValues = namedItems(section(manifest.sections, "Equivalence"))
|
|
163
|
+
const ordering: ComparisonOrdering =
|
|
164
|
+
equivalenceValues.ordering?.toLowerCase() === "unordered"
|
|
165
|
+
? "Unordered"
|
|
166
|
+
: equivalenceValues.ordering?.toLowerCase() === "per-key"
|
|
167
|
+
? "PerKey"
|
|
168
|
+
: "Ordered"
|
|
169
|
+
const gates = Object.fromEntries(
|
|
170
|
+
Object.entries(gateValues).map(([name, command]) => [name, command.split(/\s+/)])
|
|
171
|
+
)
|
|
172
|
+
const lenses = Object.entries(lensFiles)
|
|
173
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
174
|
+
.map(([name, text]) => parseReviewer(name, text))
|
|
175
|
+
const fields = manifest.fields
|
|
176
|
+
const pack: Pack = {
|
|
177
|
+
name: manifest.name,
|
|
178
|
+
source: fields.source ?? "",
|
|
179
|
+
scaffold: fields.scaffold,
|
|
180
|
+
sources: fields.sources,
|
|
181
|
+
programs: fields.programs,
|
|
182
|
+
specsDir: fields["specs-dir"] ?? "docs/specs",
|
|
183
|
+
featuresDir: fields["features-dir"] ?? "features",
|
|
184
|
+
gates,
|
|
185
|
+
replay: fields.replay?.split(/\s+/),
|
|
186
|
+
equivalence: ComparisonPolicy.make({
|
|
187
|
+
ordering,
|
|
188
|
+
ignore: new Set(
|
|
189
|
+
(equivalenceValues.ignore ?? "")
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((item) => item.trim())
|
|
192
|
+
.filter((item) => item.length > 0)
|
|
193
|
+
)
|
|
194
|
+
}),
|
|
195
|
+
judgeDimensions: dimensions(section(manifest.sections, "Judge")),
|
|
196
|
+
coverage: rules(manifest.sections, "## Coverage: "),
|
|
197
|
+
survey: rules(manifest.sections, "## Survey: "),
|
|
198
|
+
prompts,
|
|
199
|
+
lenses,
|
|
200
|
+
lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
|
|
201
|
+
dir: directory,
|
|
202
|
+
gate: (name) => gates[name],
|
|
203
|
+
prompt: (name) => prompts[name]
|
|
204
|
+
}
|
|
205
|
+
return pack
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
export const appendPackLesson = (
|
|
209
|
+
workspace: WorkspaceShape,
|
|
210
|
+
directory: string,
|
|
211
|
+
lesson: string
|
|
212
|
+
): Effect.Effect<void, WorkspaceError> =>
|
|
213
|
+
workspace.append(`${directory}/lessons.md`, `- ${lesson.trim()}\n`)
|
package/src/Package.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const packageName = "@llm4ts/flow"
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { PersistenceError, PlanParseError, UnsupportedSchemaVersion } from "./FlowError.ts"
|
|
6
|
+
import type { FlowError } from "./FlowError.ts"
|
|
7
|
+
import { parsePlan } from "./Plan.ts"
|
|
8
|
+
import type { Plan } from "./Plan.ts"
|
|
9
|
+
|
|
10
|
+
export interface PlainFileStoreShape {
|
|
11
|
+
readonly read: (path: string) => Effect.Effect<string | undefined, PersistenceError>
|
|
12
|
+
readonly writeAtomic: (path: string, contents: string) => Effect.Effect<void, PersistenceError>
|
|
13
|
+
readonly append: (path: string, contents: string) => Effect.Effect<void, PersistenceError>
|
|
14
|
+
readonly remove: (path: string) => Effect.Effect<void, PersistenceError>
|
|
15
|
+
readonly hashSha256: (path: string) => Effect.Effect<string, PersistenceError>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PlainFileStore extends Context.Service<PlainFileStore, PlainFileStoreShape>()(
|
|
19
|
+
"@llm4ts/flow/PlainFileStore"
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
const hexDigest = (buffer: ArrayBuffer): string =>
|
|
23
|
+
Array.from(new Uint8Array(buffer))
|
|
24
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
25
|
+
.join("")
|
|
26
|
+
|
|
27
|
+
const sha256Hex = (path: string, contents: string): Effect.Effect<string, PersistenceError> =>
|
|
28
|
+
Effect.tryPromise({
|
|
29
|
+
try: async () =>
|
|
30
|
+
hexDigest(
|
|
31
|
+
await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents))
|
|
32
|
+
),
|
|
33
|
+
catch: (error) =>
|
|
34
|
+
PersistenceError.make({
|
|
35
|
+
message: `failed to hash ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
36
|
+
cause: error
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export interface MemoryPlainFileStore {
|
|
41
|
+
readonly store: PlainFileStoreShape
|
|
42
|
+
readonly files: Effect.Effect<Readonly<Record<string, string>>>
|
|
43
|
+
readonly replace: (files: Readonly<Record<string, string>>) => Effect.Effect<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const makeMemoryPlainFileStore = (
|
|
47
|
+
initial: Readonly<Record<string, string>> = {}
|
|
48
|
+
): Effect.Effect<MemoryPlainFileStore> =>
|
|
49
|
+
Ref.make(initial).pipe(
|
|
50
|
+
Effect.map((state) => {
|
|
51
|
+
const store: PlainFileStoreShape = {
|
|
52
|
+
read: (path) => Ref.get(state).pipe(Effect.map((files) => files[path])),
|
|
53
|
+
writeAtomic: (path, contents) =>
|
|
54
|
+
Ref.update(state, (files) => ({ ...files, [path]: contents })),
|
|
55
|
+
append: (path, contents) =>
|
|
56
|
+
Ref.update(state, (files) => ({
|
|
57
|
+
...files,
|
|
58
|
+
[path]: `${files[path] ?? ""}${contents}`
|
|
59
|
+
})),
|
|
60
|
+
remove: (path) =>
|
|
61
|
+
Ref.update(state, (files) =>
|
|
62
|
+
Object.fromEntries(Object.entries(files).filter(([candidate]) => candidate !== path))
|
|
63
|
+
),
|
|
64
|
+
hashSha256: (path) =>
|
|
65
|
+
Ref.get(state).pipe(
|
|
66
|
+
Effect.flatMap((files) => {
|
|
67
|
+
const contents = files[path]
|
|
68
|
+
return contents === undefined
|
|
69
|
+
? Effect.fail(
|
|
70
|
+
PersistenceError.make({
|
|
71
|
+
message: `failed to hash ${path}: file does not exist`
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
: sha256Hex(path, contents)
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
store,
|
|
80
|
+
files: Ref.get(state),
|
|
81
|
+
replace: (files: Readonly<Record<string, string>>) => Ref.set(state, files)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
export interface PlanStoreShape {
|
|
87
|
+
readonly save: (path: string, plan: Plan) => Effect.Effect<void, PersistenceError>
|
|
88
|
+
readonly load: (
|
|
89
|
+
path: string
|
|
90
|
+
) => Effect.Effect<Plan | undefined, PersistenceError | PlanParseError>
|
|
91
|
+
readonly remove: (path: string) => Effect.Effect<void, PersistenceError>
|
|
92
|
+
readonly recoverOrCreate: <E, R>(
|
|
93
|
+
path: string,
|
|
94
|
+
create: Effect.Effect<Plan, E, R>
|
|
95
|
+
) => Effect.Effect<Plan, E | PersistenceError | PlanParseError, R>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const makePlanStore = (files: PlainFileStoreShape): PlanStoreShape => {
|
|
99
|
+
const save = (path: string, plan: Plan): Effect.Effect<void, PersistenceError> =>
|
|
100
|
+
files.writeAtomic(path, plan.render)
|
|
101
|
+
|
|
102
|
+
const load = (path: string): Effect.Effect<Plan | undefined, PersistenceError | PlanParseError> =>
|
|
103
|
+
Effect.flatMap(files.read(path), (contents) =>
|
|
104
|
+
contents === undefined
|
|
105
|
+
? Effect.succeed(undefined)
|
|
106
|
+
: parsePlan(contents).pipe(Effect.map((plan) => plan))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const recoverOrCreate = <E, R>(
|
|
110
|
+
path: string,
|
|
111
|
+
create: Effect.Effect<Plan, E, R>
|
|
112
|
+
): Effect.Effect<Plan, E | PersistenceError | PlanParseError, R> =>
|
|
113
|
+
Effect.flatMap(load(path), (stored) =>
|
|
114
|
+
stored === undefined ? Effect.tap(create, (plan) => save(path, plan)) : Effect.succeed(stored)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
save,
|
|
119
|
+
load,
|
|
120
|
+
remove: files.remove,
|
|
121
|
+
recoverOrCreate
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface VersionedEnvelope {
|
|
126
|
+
readonly schemaVersion: number
|
|
127
|
+
readonly value: unknown
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const VersionedEnvelope = Schema.Struct({
|
|
131
|
+
schemaVersion: Schema.Int,
|
|
132
|
+
value: Schema.Unknown
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
export const saveVersioned = Effect.fn("@llm4ts/flow/Persistence.saveVersioned")(function* <
|
|
136
|
+
A,
|
|
137
|
+
E,
|
|
138
|
+
RD,
|
|
139
|
+
RE
|
|
140
|
+
>(
|
|
141
|
+
files: PlainFileStoreShape,
|
|
142
|
+
path: string,
|
|
143
|
+
schemaVersion: number,
|
|
144
|
+
schema: Schema.ConstraintCodec<A, E, RD, RE>,
|
|
145
|
+
value: A
|
|
146
|
+
): Effect.fn.Return<void, FlowError, RE> {
|
|
147
|
+
const encoded = yield* Schema.encodeEffect(schema)(value).pipe(
|
|
148
|
+
Effect.mapError((error) =>
|
|
149
|
+
PlanParseError.make({
|
|
150
|
+
message: `failed to encode versioned document ${path}: ${String(error)}`
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
yield* files.writeAtomic(
|
|
155
|
+
path,
|
|
156
|
+
`${JSON.stringify({ schemaVersion, value: encoded }, undefined, 2)}\n`
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
export const loadVersioned = Effect.fn("@llm4ts/flow/Persistence.loadVersioned")(function* <
|
|
161
|
+
A,
|
|
162
|
+
E,
|
|
163
|
+
RD,
|
|
164
|
+
RE
|
|
165
|
+
>(
|
|
166
|
+
files: PlainFileStoreShape,
|
|
167
|
+
path: string,
|
|
168
|
+
expectedVersion: number,
|
|
169
|
+
schema: Schema.ConstraintCodec<A, E, RD, RE>
|
|
170
|
+
): Effect.fn.Return<A | undefined, FlowError, RD> {
|
|
171
|
+
const contents = yield* files.read(path)
|
|
172
|
+
if (contents === undefined) {
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
const envelope: VersionedEnvelope = yield* Schema.decodeUnknownEffect(
|
|
176
|
+
Schema.fromJsonString(VersionedEnvelope)
|
|
177
|
+
)(contents).pipe(
|
|
178
|
+
Effect.mapError((error) =>
|
|
179
|
+
PlanParseError.make({
|
|
180
|
+
message: `malformed versioned document ${path}: ${String(error)}`
|
|
181
|
+
})
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
if (envelope.schemaVersion !== expectedVersion) {
|
|
185
|
+
return yield* UnsupportedSchemaVersion.make({
|
|
186
|
+
path,
|
|
187
|
+
expected: expectedVersion,
|
|
188
|
+
actual: envelope.schemaVersion
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return yield* Schema.decodeUnknownEffect(schema)(envelope.value).pipe(
|
|
192
|
+
Effect.mapError((error) =>
|
|
193
|
+
PlanParseError.make({
|
|
194
|
+
message: `invalid versioned document ${path}: ${String(error)}`
|
|
195
|
+
})
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
})
|
package/src/Plan.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { PlanParseError } from "./FlowError.ts"
|
|
4
|
+
|
|
5
|
+
export class Task extends Schema.Class<Task>("Task")({
|
|
6
|
+
title: Schema.String,
|
|
7
|
+
description: Schema.String,
|
|
8
|
+
completed: Schema.Boolean.pipe(Schema.withConstructorDefault(Effect.succeed(false)))
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
export class Plan extends Schema.Class<Plan>("Plan")({
|
|
12
|
+
epicId: Schema.String,
|
|
13
|
+
tasks: Schema.Array(Task),
|
|
14
|
+
brief: Schema.optionalKey(Schema.String)
|
|
15
|
+
}) {
|
|
16
|
+
get nextIncomplete(): Task | undefined {
|
|
17
|
+
return this.tasks.find((task) => !task.completed)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
complete(title: string): Plan {
|
|
21
|
+
return new Plan({
|
|
22
|
+
...this,
|
|
23
|
+
tasks: this.tasks.map((task) =>
|
|
24
|
+
task.title === title ? new Task({ ...task, completed: true }) : task
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
taskPrompt(task: Task): string {
|
|
30
|
+
const brief = this.brief?.trim()
|
|
31
|
+
return brief === undefined || brief.length === 0
|
|
32
|
+
? task.description
|
|
33
|
+
: `${brief}\n\n---\n\n${task.description}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get render(): string {
|
|
37
|
+
const blocks = this.tasks.map((task) => {
|
|
38
|
+
const box = task.completed ? "[x]" : "[ ]"
|
|
39
|
+
return `## ${box} ${task.title}\n${task.description}`.trimEnd()
|
|
40
|
+
})
|
|
41
|
+
const core = [`# Plan: ${this.epicId}`, ...blocks].join("\n\n")
|
|
42
|
+
const brief = this.brief?.trim()
|
|
43
|
+
return brief === undefined || brief.length === 0 ? core : `${core}\n\n# Brief\n\n${brief}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const stableHash = (input: string): string => {
|
|
48
|
+
let hash = 0x811c9dc5
|
|
49
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
50
|
+
hash ^= input.charCodeAt(index)
|
|
51
|
+
hash = Math.imul(hash, 0x01000193)
|
|
52
|
+
}
|
|
53
|
+
return (hash >>> 0).toString(16)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const defaultPlanPath = (prompt: string, directory = ".llm4ts"): string =>
|
|
57
|
+
`${directory}/plan-${stableHash(prompt)}.md`
|
|
58
|
+
|
|
59
|
+
const parseTask = (chunk: string): Effect.Effect<Task, PlanParseError> => {
|
|
60
|
+
const lines = chunk.split("\n")
|
|
61
|
+
const head = lines[0] ?? ""
|
|
62
|
+
const match = /^## \[([ xX])\] (.+)$/.exec(head)
|
|
63
|
+
const marker = match?.[1]
|
|
64
|
+
const title = match?.[2]?.trim()
|
|
65
|
+
return marker === undefined || title === undefined
|
|
66
|
+
? Effect.fail(
|
|
67
|
+
PlanParseError.make({
|
|
68
|
+
message: head.length === 0 ? "empty task chunk" : `malformed task header: ${head}`
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
: Effect.succeed(
|
|
72
|
+
Task.make({
|
|
73
|
+
title,
|
|
74
|
+
description: lines.slice(1).join("\n").trim(),
|
|
75
|
+
completed: marker.toLowerCase() === "x"
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const splitBrief = (body: string): readonly [tasks: string, brief: string | undefined] => {
|
|
81
|
+
const lines = body.split("\n")
|
|
82
|
+
const index = lines.findIndex((line) => line.trim() === "# Brief")
|
|
83
|
+
if (index < 0) {
|
|
84
|
+
return [body.trim(), undefined]
|
|
85
|
+
}
|
|
86
|
+
const brief = lines
|
|
87
|
+
.slice(index + 1)
|
|
88
|
+
.join("\n")
|
|
89
|
+
.trim()
|
|
90
|
+
return [lines.slice(0, index).join("\n").trim(), brief.length === 0 ? undefined : brief]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const parsePlan = Effect.fn("@llm4ts/flow/Plan.parse")(function* (
|
|
94
|
+
markdown: string
|
|
95
|
+
): Effect.fn.Return<Plan, PlanParseError> {
|
|
96
|
+
const stripped = markdown.trim()
|
|
97
|
+
const lines = stripped.split("\n")
|
|
98
|
+
const header = lines[0] ?? ""
|
|
99
|
+
const prefix = "# Plan: "
|
|
100
|
+
if (!header.startsWith(prefix)) {
|
|
101
|
+
return yield* PlanParseError.make({
|
|
102
|
+
message: `expected a '${prefix}<epicId>' header`
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
const [taskBody, brief] = splitBrief(lines.slice(1).join("\n").trim())
|
|
106
|
+
const chunks =
|
|
107
|
+
taskBody.length === 0
|
|
108
|
+
? []
|
|
109
|
+
: taskBody
|
|
110
|
+
.split(/^(?=## )/m)
|
|
111
|
+
.map((chunk) => chunk.trim())
|
|
112
|
+
.filter((chunk) => chunk.length > 0)
|
|
113
|
+
const tasks = yield* Effect.forEach(chunks, parseTask)
|
|
114
|
+
return Plan.make({
|
|
115
|
+
epicId: header.slice(prefix.length).trim(),
|
|
116
|
+
tasks,
|
|
117
|
+
...(brief === undefined ? {} : { brief })
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
|
|
3
|
+
import type { FlowError } from "./FlowError.ts"
|
|
4
|
+
import type { Plan, Task } from "./Plan.ts"
|
|
5
|
+
import type { PlanStoreShape } from "./Persistence.ts"
|
|
6
|
+
|
|
7
|
+
const errorMessage = (error: unknown): string =>
|
|
8
|
+
error instanceof Error && error.message.length > 0 ? error.message : String(error)
|
|
9
|
+
|
|
10
|
+
export const stage = <A, E, R>(
|
|
11
|
+
events: FlowEventsShape,
|
|
12
|
+
name: string,
|
|
13
|
+
effect: Effect.Effect<A, E, R>
|
|
14
|
+
): Effect.Effect<A, E, R> =>
|
|
15
|
+
events.publish(StageStarted.make({ stage: name })).pipe(
|
|
16
|
+
Effect.andThen(effect),
|
|
17
|
+
Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
|
|
18
|
+
Effect.tapError((error) =>
|
|
19
|
+
events.publish(
|
|
20
|
+
StageFailed.make({
|
|
21
|
+
stage: name,
|
|
22
|
+
message: errorMessage(error)
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export const implementTaskLoop = Effect.fn("@llm4ts/flow/PlanExecution.implementTaskLoop")(
|
|
29
|
+
function* <E, R>(
|
|
30
|
+
store: PlanStoreShape,
|
|
31
|
+
events: FlowEventsShape,
|
|
32
|
+
planPath: string,
|
|
33
|
+
plan: Plan,
|
|
34
|
+
perTask: (task: Task) => Effect.Effect<void, E, R>
|
|
35
|
+
): Effect.fn.Return<Plan, E | FlowError, R> {
|
|
36
|
+
let current = plan
|
|
37
|
+
for (const task of plan.tasks) {
|
|
38
|
+
if (!task.completed) {
|
|
39
|
+
yield* stage(events, task.title, perTask(task))
|
|
40
|
+
current = current.complete(task.title)
|
|
41
|
+
yield* store.save(planPath, current)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return current
|
|
45
|
+
}
|
|
46
|
+
)
|