ocpipe 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/pipeline.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * DSTS SDK Pipeline orchestrator.
3
+ *
4
+ * Manages execution context, state, checkpointing, logging, retry logic, and sub-pipelines.
5
+ */
6
+
7
+ import { writeFile, readFile, readdir, mkdir } from 'fs/promises'
8
+ import type { Module } from './module.js'
9
+ import type {
10
+ BaseState,
11
+ ExecutionContext,
12
+ PipelineConfig,
13
+ RunOptions,
14
+ StepResult,
15
+ } from './types.js'
16
+ import { logStep } from './agent.js'
17
+ import { JsonParseError, SchemaValidationError } from './parsing.js'
18
+
19
+ /** Pipeline orchestrates workflow execution with state management. */
20
+ export class Pipeline<S extends BaseState> {
21
+ state: S
22
+ private ctx: ExecutionContext
23
+ private stepNumber = 0
24
+
25
+ constructor(
26
+ public readonly config: PipelineConfig,
27
+ createState: () => S,
28
+ ) {
29
+ this.state = createState()
30
+ this.ctx = {
31
+ sessionId: undefined,
32
+ defaultModel: config.defaultModel,
33
+ defaultAgent: config.defaultAgent,
34
+ timeoutSec: config.timeoutSec ?? 300,
35
+ }
36
+ }
37
+
38
+ /** run executes a module and records the result. */
39
+ async run<I, O>(
40
+ module: Module<I, O>,
41
+ input: I,
42
+ options?: RunOptions,
43
+ ): Promise<StepResult<O>> {
44
+ const stepName = options?.name ?? module.constructor.name
45
+ this.stepNumber++
46
+ logStep(this.stepNumber, stepName)
47
+
48
+ // Handle session control
49
+ if (options?.newSession) {
50
+ this.ctx.sessionId = undefined
51
+ }
52
+
53
+ // Handle model override
54
+ const originalModel = this.ctx.defaultModel
55
+ if (options?.model) {
56
+ this.ctx.defaultModel = options.model
57
+ }
58
+
59
+ const startTime = Date.now()
60
+ let lastError: Error | undefined
61
+ const retryConfig = options?.retry ?? this.config.retry ?? { maxAttempts: 1 }
62
+
63
+ for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt++) {
64
+ try {
65
+ const data = await module.forward(input, this.ctx)
66
+
67
+ // Restore original model
68
+ this.ctx.defaultModel = originalModel
69
+
70
+ const result: StepResult<O> = {
71
+ data,
72
+ stepName,
73
+ duration: Date.now() - startTime,
74
+ sessionId: this.ctx.sessionId ?? '',
75
+ model: options?.model ?? this.config.defaultModel,
76
+ attempt,
77
+ }
78
+
79
+ // Record step for checkpointing
80
+ this.state.steps.push({
81
+ stepName,
82
+ timestamp: new Date().toISOString(),
83
+ result: result as StepResult<unknown>,
84
+ })
85
+
86
+ // Update opencode session in state
87
+ this.state.opencodeSessionId = this.ctx.sessionId
88
+
89
+ await this.saveCheckpoint()
90
+ return result
91
+ } catch (err) {
92
+ lastError = err as Error
93
+ const isParseError = err instanceof JsonParseError
94
+ const isSchemaError = err instanceof SchemaValidationError
95
+
96
+ // Don't retry SchemaValidationError - corrections already attempted
97
+ if (isSchemaError) {
98
+ console.error(
99
+ `Step ${stepName} failed with schema validation error (corrections exhausted): ${lastError.message}`,
100
+ )
101
+ break
102
+ }
103
+
104
+ if (
105
+ attempt < retryConfig.maxAttempts &&
106
+ (!isParseError || retryConfig.onParseError)
107
+ ) {
108
+ console.error(
109
+ `Step ${stepName} failed (attempt ${attempt}/${retryConfig.maxAttempts}): ${lastError.message}`,
110
+ )
111
+ continue
112
+ }
113
+ break
114
+ }
115
+ }
116
+
117
+ // Save checkpoint before throwing
118
+ await this.saveCheckpoint()
119
+ throw lastError
120
+ }
121
+
122
+ /** runSub executes a sub-pipeline with its own session. */
123
+ async runSub<SS extends BaseState, T>(
124
+ subConfig: PipelineConfig,
125
+ createState: () => SS,
126
+ executor: (sub: Pipeline<SS>) => Promise<T>,
127
+ ): Promise<StepResult<T>> {
128
+ this.stepNumber++
129
+ logStep(this.stepNumber, `sub:${subConfig.name}`)
130
+
131
+ const subPipeline = new Pipeline(subConfig, createState)
132
+ const startTime = Date.now()
133
+
134
+ const data = await executor(subPipeline)
135
+
136
+ // Record sub-pipeline reference
137
+ this.state.subPipelines.push({
138
+ name: subConfig.name,
139
+ sessionId: subPipeline.ctx.sessionId ?? '',
140
+ timestamp: new Date().toISOString(),
141
+ state: subPipeline.state,
142
+ })
143
+
144
+ await this.saveCheckpoint()
145
+
146
+ return {
147
+ data,
148
+ stepName: `sub:${subConfig.name}`,
149
+ duration: Date.now() - startTime,
150
+ sessionId: subPipeline.ctx.sessionId ?? '',
151
+ model: subConfig.defaultModel,
152
+ attempt: 1,
153
+ }
154
+ }
155
+
156
+ /** getSessionId returns the current OpenCode session ID. */
157
+ getSessionId(): string | undefined {
158
+ return this.ctx.sessionId
159
+ }
160
+
161
+ /** setPhase updates the current phase in state. */
162
+ setPhase(phase: string): void {
163
+ this.state.phase = phase
164
+ }
165
+
166
+ /** saveCheckpoint persists the current state to disk. */
167
+ async saveCheckpoint(): Promise<void> {
168
+ await mkdir(this.config.checkpointDir, { recursive: true })
169
+ const path = `${this.config.checkpointDir}/${this.config.name}_${this.state.sessionId}.json`
170
+ await writeFile(path, JSON.stringify(this.state, null, 2))
171
+ }
172
+
173
+ /** loadCheckpoint loads a pipeline from a checkpoint file. */
174
+ static async loadCheckpoint<S extends BaseState>(
175
+ config: PipelineConfig,
176
+ sessionId: string,
177
+ ): Promise<Pipeline<S> | null> {
178
+ const path = `${config.checkpointDir}/${config.name}_${sessionId}.json`
179
+
180
+ try {
181
+ const content = await readFile(path, 'utf-8')
182
+ const state = JSON.parse(content) as S
183
+ const pipeline = new Pipeline<S>(config, () => state)
184
+ pipeline.state = state
185
+
186
+ // Restore context from state
187
+ if (state.opencodeSessionId) {
188
+ pipeline.ctx.sessionId = state.opencodeSessionId
189
+ }
190
+
191
+ // Restore step number
192
+ pipeline.stepNumber = state.steps.length
193
+
194
+ return pipeline
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ /** listCheckpoints lists all checkpoint files for a pipeline name. */
201
+ static async listCheckpoints(config: PipelineConfig): Promise<string[]> {
202
+ try {
203
+ const allFiles = await readdir(config.checkpointDir)
204
+ const prefix = `${config.name}_`
205
+ const files = allFiles
206
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.json'))
207
+ .map((f) => `${config.checkpointDir}/${f}`)
208
+ return files.sort().reverse() // Most recent first
209
+ } catch {
210
+ return []
211
+ }
212
+ }
213
+ }
package/predict.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * DSTS SDK Predict class.
3
+ *
4
+ * Executes a signature by generating a prompt, calling OpenCode, and parsing the response.
5
+ */
6
+
7
+ import { z } from 'zod'
8
+ import type {
9
+ CorrectionConfig,
10
+ CorrectionMethod,
11
+ ExecutionContext,
12
+ FieldConfig,
13
+ FieldError,
14
+ InferInputs,
15
+ InferOutputs,
16
+ ModelConfig,
17
+ PredictResult,
18
+ SignatureDef,
19
+ } from './types.js'
20
+ import { runAgent } from './agent.js'
21
+ import {
22
+ tryParseResponse,
23
+ // jq-style patches
24
+ buildPatchPrompt,
25
+ buildBatchPatchPrompt,
26
+ extractPatch,
27
+ applyJqPatch,
28
+ // JSON Patch (RFC 6902)
29
+ buildJsonPatchPrompt,
30
+ buildBatchJsonPatchPrompt,
31
+ extractJsonPatch,
32
+ applyJsonPatch,
33
+ SchemaValidationError,
34
+ } from './parsing.js'
35
+
36
+ /** Configuration for a Predict instance. */
37
+ export interface PredictConfig {
38
+ /** Override the pipeline's default agent. */
39
+ agent?: string
40
+ /** Override the pipeline's default model. */
41
+ model?: ModelConfig
42
+ /** Start a fresh session (default: false, reuses context). */
43
+ newSession?: boolean
44
+ /** Custom prompt template function. */
45
+ template?: (inputs: Record<string, unknown>) => string
46
+ /** Schema correction options (enabled by default, set to false to disable). */
47
+ correction?: CorrectionConfig | false
48
+ }
49
+
50
+ /** Predict executes a signature by calling an LLM and parsing the response. */
51
+ export class Predict<S extends SignatureDef<any, any>> {
52
+ constructor(
53
+ public readonly sig: S,
54
+ public readonly config: PredictConfig = {},
55
+ ) {}
56
+
57
+ /** execute runs the prediction with the given inputs. */
58
+ async execute(
59
+ inputs: InferInputs<S>,
60
+ ctx: ExecutionContext,
61
+ ): Promise<PredictResult<InferOutputs<S>>> {
62
+ const prompt = this.buildPrompt(inputs as Record<string, unknown>)
63
+ const startTime = Date.now()
64
+
65
+ const agentResult = await runAgent({
66
+ prompt,
67
+ agent: this.config.agent ?? ctx.defaultAgent,
68
+ model: this.config.model ?? ctx.defaultModel,
69
+ sessionId: this.config.newSession ? undefined : ctx.sessionId,
70
+ timeoutSec: ctx.timeoutSec,
71
+ })
72
+
73
+ // Update context with new session ID for continuity
74
+ ctx.sessionId = agentResult.sessionId
75
+
76
+ const parseResult = tryParseResponse<InferOutputs<S>>(
77
+ agentResult.text,
78
+ this.sig.outputs,
79
+ )
80
+
81
+ // If parsing succeeded, return the result
82
+ if (parseResult.ok && parseResult.data) {
83
+ return {
84
+ data: parseResult.data,
85
+ raw: agentResult.text,
86
+ sessionId: agentResult.sessionId,
87
+ duration: Date.now() - startTime,
88
+ model: this.config.model ?? ctx.defaultModel,
89
+ }
90
+ }
91
+
92
+ // Parsing failed - attempt correction if enabled
93
+ if (this.config.correction !== false && parseResult.errors && parseResult.json) {
94
+ const corrected = await this.correctFields(
95
+ parseResult.json,
96
+ parseResult.errors,
97
+ ctx,
98
+ agentResult.sessionId,
99
+ )
100
+
101
+ if (corrected) {
102
+ return {
103
+ data: corrected,
104
+ raw: agentResult.text,
105
+ sessionId: agentResult.sessionId,
106
+ duration: Date.now() - startTime,
107
+ model: this.config.model ?? ctx.defaultModel,
108
+ }
109
+ }
110
+ }
111
+
112
+ // Correction failed or disabled - throw SchemaValidationError (non-retryable)
113
+ const errors = parseResult.errors ?? []
114
+ const errorMessages = errors.map((e) => `${e.path}: ${e.message}`).join('; ') || 'Unknown error'
115
+ const correctionAttempts = this.config.correction !== false ? (typeof this.config.correction === 'object' ? this.config.correction.maxRounds ?? 3 : 3) : 0
116
+ throw new SchemaValidationError(`Schema validation failed: ${errorMessages}`, errors, correctionAttempts)
117
+ }
118
+
119
+ /** correctFields attempts to fix field errors using same-session patches with retries. */
120
+ private async correctFields(
121
+ json: Record<string, unknown>,
122
+ initialErrors: FieldError[],
123
+ ctx: ExecutionContext,
124
+ sessionId: string,
125
+ ): Promise<InferOutputs<S> | null> {
126
+ const correctionConfig = typeof this.config.correction === 'object' ? this.config.correction : {}
127
+ const method: CorrectionMethod = correctionConfig.method ?? 'json-patch'
128
+ const maxFields = correctionConfig.maxFields ?? 5
129
+ const maxRounds = correctionConfig.maxRounds ?? 3
130
+ const correctionModel = correctionConfig.model
131
+
132
+ let currentJson = JSON.parse(JSON.stringify(json)) as Record<string, unknown>
133
+ let currentErrors = initialErrors
134
+
135
+ for (let round = 1; round <= maxRounds; round++) {
136
+ const errorsToFix = currentErrors.slice(0, maxFields)
137
+
138
+ if (errorsToFix.length === 0) {
139
+ break
140
+ }
141
+
142
+ console.error(`\n>>> Correction round ${round}/${maxRounds} [${method}]: fixing ${errorsToFix.length} field(s)...`)
143
+
144
+ // Build prompt based on correction method
145
+ const patchPrompt = method === 'jq'
146
+ ? (errorsToFix.length === 1
147
+ ? buildPatchPrompt(errorsToFix[0]!, currentJson, this.sig.outputs)
148
+ : buildBatchPatchPrompt(errorsToFix, currentJson))
149
+ : (errorsToFix.length === 1
150
+ ? buildJsonPatchPrompt(errorsToFix[0]!, currentJson, this.sig.outputs)
151
+ : buildBatchJsonPatchPrompt(errorsToFix, currentJson))
152
+
153
+ // Use same session (model has context) unless correction model specified
154
+ const patchResult = await runAgent({
155
+ prompt: patchPrompt,
156
+ model: correctionModel ?? ctx.defaultModel,
157
+ sessionId: correctionModel ? undefined : sessionId,
158
+ agent: ctx.defaultAgent,
159
+ timeoutSec: 60, // Short timeout for simple patches
160
+ })
161
+
162
+ // Extract and apply the patch based on method
163
+ if (method === 'jq') {
164
+ const patch = extractPatch(patchResult.text)
165
+ console.error(` jq patch: ${patch}`)
166
+ currentJson = applyJqPatch(currentJson, patch)
167
+ } else {
168
+ const operations = extractJsonPatch(patchResult.text)
169
+ console.error(` JSON Patch: ${JSON.stringify(operations)}`)
170
+ currentJson = applyJsonPatch(currentJson, operations)
171
+ }
172
+
173
+ // Re-validate the corrected JSON
174
+ const revalidated = tryParseResponse<InferOutputs<S>>(
175
+ JSON.stringify(currentJson),
176
+ this.sig.outputs,
177
+ )
178
+
179
+ if (revalidated.ok && revalidated.data) {
180
+ console.error(` Schema correction successful after ${round} round(s)!`)
181
+ return revalidated.data
182
+ }
183
+
184
+ // Update errors for next round
185
+ currentErrors = revalidated.errors ?? []
186
+
187
+ if (currentErrors.length === 0) {
188
+ // No errors but also no data? Shouldn't happen, but handle gracefully
189
+ console.error(` Unexpected state: no errors but validation failed`)
190
+ break
191
+ }
192
+
193
+ console.error(` Round ${round} complete, ${currentErrors.length} error(s) remaining`)
194
+ }
195
+
196
+ console.error(` Schema correction failed after ${maxRounds} rounds`)
197
+ return null
198
+ }
199
+
200
+ /** buildPrompt generates the prompt from inputs. */
201
+ private buildPrompt(inputs: Record<string, unknown>): string {
202
+ // Allow custom template override
203
+ if (this.config.template) {
204
+ return this.config.template(inputs)
205
+ }
206
+ return this.generatePrompt(inputs)
207
+ }
208
+
209
+ /** generatePrompt creates a structured prompt from the signature and inputs. */
210
+ private generatePrompt(inputs: Record<string, unknown>): string {
211
+ const lines: string[] = []
212
+
213
+ // Task description from signature doc
214
+ lines.push(this.sig.doc)
215
+ lines.push('')
216
+
217
+ // Input fields as JSON
218
+ const inputsWithDescriptions: Record<string, unknown> = {}
219
+ for (const [name, config] of Object.entries(this.sig.inputs) as [
220
+ string,
221
+ FieldConfig,
222
+ ][]) {
223
+ inputsWithDescriptions[name] = inputs[name]
224
+ }
225
+ lines.push('INPUTS:')
226
+ lines.push('```json')
227
+ lines.push(JSON.stringify(inputsWithDescriptions, null, 2))
228
+ lines.push('```')
229
+ lines.push('')
230
+
231
+ // Output format with JSON Schema
232
+ lines.push('OUTPUT FORMAT:')
233
+ lines.push('Return a JSON object matching this schema EXACTLY.')
234
+ lines.push('IMPORTANT: For optional fields, OMIT the field entirely - do NOT use null.')
235
+ lines.push('')
236
+ lines.push('```json')
237
+ lines.push(JSON.stringify(this.buildOutputJsonSchema(), null, 2))
238
+ lines.push('```')
239
+
240
+ return lines.join('\n')
241
+ }
242
+
243
+ /** buildOutputJsonSchema creates a JSON Schema from the output field definitions. */
244
+ private buildOutputJsonSchema(): Record<string, unknown> {
245
+ // Build a Zod object from the output fields
246
+ const shape: Record<string, z.ZodType> = {}
247
+ for (const [name, config] of Object.entries(this.sig.outputs) as [string, FieldConfig][]) {
248
+ shape[name] = config.type
249
+ }
250
+ const outputSchema = z.object(shape)
251
+
252
+ // Convert to JSON Schema
253
+ const jsonSchema = z.toJSONSchema(outputSchema)
254
+
255
+ // Add field descriptions from our config (toJSONSchema uses .describe() metadata)
256
+ // Since our FieldConfig has a separate desc field, merge it in
257
+ const props = jsonSchema.properties as Record<string, Record<string, unknown>> | undefined
258
+ if (props) {
259
+ for (const [name, config] of Object.entries(this.sig.outputs) as [string, FieldConfig][]) {
260
+ if (config.desc && props[name]) {
261
+ // Only add if not already set by .describe()
262
+ if (!props[name].description) {
263
+ props[name].description = config.desc
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ return jsonSchema
270
+ }
271
+ }
package/signature.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * DSTS SDK signature definition.
3
+ *
4
+ * Signatures declare input/output contracts for LLM interactions using Zod for validation.
5
+ */
6
+
7
+ import { z } from 'zod'
8
+ import type { FieldConfig, SignatureDef } from './types.js'
9
+
10
+ /** signature creates a new signature definition. */
11
+ export function signature<
12
+ I extends Record<string, FieldConfig>,
13
+ O extends Record<string, FieldConfig>,
14
+ >(def: { doc: string; inputs: I; outputs: O }): SignatureDef<I, O> {
15
+ return def
16
+ }
17
+
18
+ /** field provides helper functions for creating common field configurations. */
19
+ export const field = {
20
+ /** string creates a string field. */
21
+ string: (desc?: string): FieldConfig<z.ZodString> => ({
22
+ type: z.string(),
23
+ desc,
24
+ }),
25
+
26
+ /** number creates a number field. */
27
+ number: (desc?: string): FieldConfig<z.ZodNumber> => ({
28
+ type: z.number(),
29
+ desc,
30
+ }),
31
+
32
+ /** boolean creates a boolean field. */
33
+ boolean: (desc?: string): FieldConfig<z.ZodBoolean> => ({
34
+ type: z.boolean(),
35
+ desc,
36
+ }),
37
+
38
+ /** array creates an array field with the specified item type. */
39
+ array: <T extends z.ZodType>(
40
+ itemType: T,
41
+ desc?: string,
42
+ ): FieldConfig<z.ZodArray<T>> => ({
43
+ type: z.array(itemType),
44
+ desc,
45
+ }),
46
+
47
+ /** object creates an object field with the specified shape. */
48
+ object: <T extends z.ZodRawShape>(
49
+ shape: T,
50
+ desc?: string,
51
+ ): FieldConfig<z.ZodObject<T>> => ({
52
+ type: z.object(shape),
53
+ desc,
54
+ }),
55
+
56
+ /** enum creates an enum field with the specified values. */
57
+ enum: <const T extends readonly [string, ...string[]]>(
58
+ values: T,
59
+ desc?: string,
60
+ ) => ({
61
+ type: z.enum(values),
62
+ desc,
63
+ }),
64
+
65
+ /** optional wraps a field type to make it optional. */
66
+ optional: <T extends z.ZodType>(
67
+ fieldConfig: FieldConfig<T>,
68
+ ): FieldConfig<z.ZodOptional<T>> => ({
69
+ type: fieldConfig.type.optional(),
70
+ desc: fieldConfig.desc,
71
+ }),
72
+
73
+ /** nullable wraps a field type to make it nullable. */
74
+ nullable: <T extends z.ZodType>(
75
+ fieldConfig: FieldConfig<T>,
76
+ ): FieldConfig<z.ZodNullable<T>> => ({
77
+ type: fieldConfig.type.nullable(),
78
+ desc: fieldConfig.desc,
79
+ }),
80
+
81
+ /** custom creates a field with a custom Zod type. */
82
+ custom: <T extends z.ZodType>(type: T, desc?: string): FieldConfig<T> => ({
83
+ type,
84
+ desc,
85
+ }),
86
+ }
87
+
88
+ /** buildOutputSchema creates a Zod object schema from output field definitions. */
89
+ export function buildOutputSchema<O extends Record<string, FieldConfig>>(
90
+ outputs: O,
91
+ ): z.ZodObject<{ [K in keyof O]: O[K]['type'] }> {
92
+ const shape = {} as { [K in keyof O]: O[K]['type'] }
93
+ for (const [key, config] of Object.entries(outputs)) {
94
+ ;(shape as Record<string, z.ZodType>)[key] = config.type
95
+ }
96
+ return z.object(shape)
97
+ }
package/state.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * DSTS SDK state management.
3
+ *
4
+ * Provides base state types and helpers for checkpointable workflow state.
5
+ */
6
+
7
+ import type { BaseState } from './types.js'
8
+
9
+ /** createSessionId generates a unique session ID based on the current timestamp. */
10
+ export function createSessionId(): string {
11
+ const now = new Date()
12
+ const id = now
13
+ .toISOString()
14
+ .replace(/[-:]/g, '')
15
+ .replace('T', '_')
16
+ .split('.')[0]
17
+ return id ?? ''
18
+ }
19
+
20
+ /** createBaseState creates a new base state with default values. */
21
+ export function createBaseState(): BaseState {
22
+ return {
23
+ sessionId: createSessionId(),
24
+ startedAt: new Date().toISOString(),
25
+ phase: 'init',
26
+ steps: [],
27
+ subPipelines: [],
28
+ }
29
+ }
30
+
31
+ /** extendBaseState creates a state factory that extends BaseState with additional fields. */
32
+ export function extendBaseState<T extends Record<string, unknown>>(
33
+ extension: T,
34
+ ): BaseState & T {
35
+ return {
36
+ ...createBaseState(),
37
+ ...extension,
38
+ }
39
+ }