ocpipe 0.6.9 → 0.6.11

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/omp.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * ocpipe Oh My Pi integration.
3
+ *
4
+ * Runs Oh My Pi through its headless JSON print mode.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from 'child_process'
8
+ import { isAbsolute, join } from 'path'
9
+ import { PROJECT_ROOT } from './paths.js'
10
+ import type { OmpOptions, RunAgentOptions, RunAgentResult } from './types.js'
11
+
12
+ interface OmpProcessRequest {
13
+ command: string
14
+ args: string[]
15
+ cwd: string
16
+ env: NodeJS.ProcessEnv
17
+ signal?: AbortSignal
18
+ }
19
+
20
+ interface OmpProcessResult {
21
+ stdout: string
22
+ stderr: string
23
+ exitCode: number | null
24
+ signal: NodeJS.Signals | null
25
+ }
26
+
27
+ export interface OmpProcess {
28
+ run(req: OmpProcessRequest): Promise<OmpProcessResult>
29
+ }
30
+
31
+ interface OmpRunSummary {
32
+ sawJsonEvent: boolean
33
+ sessionId: string
34
+ finalMessage: string
35
+ }
36
+
37
+ const defaultOmpCommand = 'omp'
38
+ const defaultOmpThinking = 'high'
39
+ const defaultOmpApprovalMode = 'yolo'
40
+
41
+ /** runOmpAgent executes an Oh My Pi coding-agent turn. */
42
+ export async function runOmpAgent(
43
+ options: RunAgentOptions,
44
+ processRunner: OmpProcess = commandOmpProcess,
45
+ ): Promise<RunAgentResult> {
46
+ const {
47
+ prompt,
48
+ model,
49
+ sessionId,
50
+ timeoutSec = 3600,
51
+ workdir,
52
+ omp,
53
+ signal,
54
+ } = options
55
+
56
+ if (signal?.aborted) {
57
+ throw new Error('Request aborted')
58
+ }
59
+
60
+ const cwd = workdir ?? PROJECT_ROOT
61
+ const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
62
+ const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
63
+ console.error(
64
+ `\n>>> OMP [${model.modelID}] ${sessionInfo}: ${promptPreview}...`,
65
+ )
66
+
67
+ const abort = new AbortController()
68
+ const abortHandler = () => abort.abort()
69
+ signal?.addEventListener('abort', abortHandler, { once: true })
70
+ let timedOut = false
71
+ const timeout =
72
+ timeoutSec > 0 ?
73
+ setTimeout(() => {
74
+ timedOut = true
75
+ abort.abort()
76
+ }, timeoutSec * 1000)
77
+ : null
78
+
79
+ try {
80
+ const result = await processRunner.run({
81
+ command: omp?.command ?? defaultOmpCommand,
82
+ args: buildOmpArgs(model.modelID, cwd, sessionId, prompt, omp),
83
+ cwd: omp?.processCwd ?? cwd,
84
+ env: buildOmpEnv(omp),
85
+ signal: abort.signal,
86
+ })
87
+ const summary = parseOmpOutput(result.stdout)
88
+ const detail =
89
+ result.signal ? `signal ${result.signal}` : `status ${result.exitCode}`
90
+ if (result.exitCode !== 0 || result.signal) {
91
+ const message = firstNonEmpty(
92
+ summary.finalMessage,
93
+ result.stderr.trim(),
94
+ result.stdout.trim(),
95
+ detail,
96
+ )
97
+ throw new Error(`OMP exited with ${detail}: ${message}`)
98
+ }
99
+
100
+ const response = firstNonEmpty(
101
+ summary.finalMessage,
102
+ summary.sawJsonEvent ? '' : result.stdout.trim(),
103
+ )
104
+ if (!response) {
105
+ throw new Error('OMP returned an empty final message')
106
+ }
107
+
108
+ const nextSessionId = firstNonEmpty(summary.sessionId, sessionId ?? '')
109
+ console.error(
110
+ `<<< OMP done (${response.length} chars)${nextSessionId ? ` [session:${nextSessionId}]` : ''}`,
111
+ )
112
+ return {
113
+ text: response,
114
+ sessionId: nextSessionId,
115
+ }
116
+ } catch (err) {
117
+ if (timedOut) {
118
+ throw new Error(`Timeout after ${timeoutSec}s`, { cause: err })
119
+ }
120
+ if (signal?.aborted) {
121
+ throw new Error('Request aborted', { cause: err })
122
+ }
123
+ throw err
124
+ } finally {
125
+ if (timeout) clearTimeout(timeout)
126
+ signal?.removeEventListener('abort', abortHandler)
127
+ }
128
+ }
129
+
130
+ function buildOmpArgs(
131
+ modelID: string,
132
+ cwd: string,
133
+ sessionId: string | undefined,
134
+ prompt: string,
135
+ omp: OmpOptions | undefined,
136
+ ): string[] {
137
+ const args = ['--print', '--mode', 'json', '--cwd', cwd]
138
+ if (modelID) {
139
+ args.push('--model', modelID)
140
+ }
141
+ if (hasOwn(omp, 'codexHome')) {
142
+ args.push('--codex-home', omp?.codexHome ?? '')
143
+ }
144
+ if (sessionId) {
145
+ args.push('--resume', sessionId)
146
+ }
147
+ if (omp?.goalMode || firstNonEmpty(omp?.goalObjective ?? '')) {
148
+ const objective = firstNonEmpty(omp?.goalObjective ?? '', prompt)
149
+ if (objective) {
150
+ args.push('--goal', objective)
151
+ }
152
+ }
153
+ if (
154
+ typeof omp?.contextStopPercent === 'number' &&
155
+ omp.contextStopPercent > 0
156
+ ) {
157
+ args.push('--context-stop-percent', String(omp.contextStopPercent))
158
+ }
159
+ if (typeof omp?.contextStopTokens === 'number' && omp.contextStopTokens > 0) {
160
+ args.push('--context-stop-tokens', String(omp.contextStopTokens))
161
+ }
162
+ if (omp?.scratchHandoffFile) {
163
+ args.push(
164
+ '--scratch-handoff-file',
165
+ resolveOmpPath(cwd, omp.scratchHandoffFile),
166
+ )
167
+ }
168
+ if (omp?.autoApprove ?? true) {
169
+ args.push('--auto-approve')
170
+ }
171
+ const approvalMode = omp?.approvalMode ?? defaultOmpApprovalMode
172
+ if (approvalMode) {
173
+ args.push('--approval-mode', approvalMode)
174
+ }
175
+ const thinking = omp?.thinking ?? defaultOmpThinking
176
+ if (thinking) {
177
+ args.push('--thinking', thinking)
178
+ }
179
+ args.push(...(omp?.extraArgs ?? []))
180
+ if (prompt) {
181
+ args.push('--', prompt)
182
+ }
183
+ return args
184
+ }
185
+
186
+ function buildOmpEnv(omp: OmpOptions | undefined): NodeJS.ProcessEnv {
187
+ const env: NodeJS.ProcessEnv = { ...process.env, ...omp?.env }
188
+ if (hasOwn(omp, 'codexHome')) {
189
+ delete env.CODEX_HOME
190
+ }
191
+ if (omp?.home) {
192
+ env.OMP_HOME = omp.home
193
+ }
194
+ return env
195
+ }
196
+
197
+ function resolveOmpPath(cwd: string, path: string): string {
198
+ return isAbsolute(path) ? path : join(cwd, path)
199
+ }
200
+
201
+ function parseOmpOutput(stdout: string): OmpRunSummary {
202
+ const summary: OmpRunSummary = {
203
+ sawJsonEvent: false,
204
+ sessionId: '',
205
+ finalMessage: '',
206
+ }
207
+ for (const rawLine of stdout.split('\n')) {
208
+ const line = rawLine.trim()
209
+ if (!line) continue
210
+ let event: unknown
211
+ try {
212
+ event = JSON.parse(line)
213
+ } catch {
214
+ continue
215
+ }
216
+ if (!isRecord(event)) continue
217
+ summary.sawJsonEvent = true
218
+ const type = ompString(event.type)
219
+ if (type === 'session') {
220
+ summary.sessionId = firstNonEmpty(
221
+ ompString(event.id),
222
+ ompString(event.sessionId),
223
+ ompString(event.session_id),
224
+ summary.sessionId,
225
+ )
226
+ continue
227
+ }
228
+ if (type === 'message_end') {
229
+ const text = ompAssistantText(event.message)
230
+ if (text) {
231
+ summary.finalMessage = text
232
+ }
233
+ }
234
+ }
235
+ if (!summary.sawJsonEvent) {
236
+ summary.finalMessage = stdout.trim()
237
+ }
238
+ return summary
239
+ }
240
+
241
+ function ompAssistantText(raw: unknown): string {
242
+ if (!isRecord(raw) || ompString(raw.role) !== 'assistant') return ''
243
+ if (Array.isArray(raw.content)) {
244
+ const parts: string[] = []
245
+ for (const rawPart of raw.content) {
246
+ if (!isRecord(rawPart) || ompString(rawPart.type) !== 'text') continue
247
+ const text = ompString(rawPart.text).trim()
248
+ if (text) parts.push(text)
249
+ }
250
+ return parts.join('\n\n')
251
+ }
252
+ return ompString(raw.content).trim()
253
+ }
254
+
255
+ const commandOmpProcess: OmpProcess = {
256
+ run(req) {
257
+ const { promise, resolve, reject } =
258
+ Promise.withResolvers<OmpProcessResult>()
259
+ let child: ChildProcess
260
+ try {
261
+ child = spawn(req.command, req.args, {
262
+ cwd: req.cwd,
263
+ env: req.env,
264
+ stdio: ['ignore', 'pipe', 'pipe'],
265
+ })
266
+ } catch (err) {
267
+ reject(err)
268
+ return promise
269
+ }
270
+
271
+ const stdoutChunks: Buffer[] = []
272
+ const stderrChunks: Buffer[] = []
273
+ const abortHandler = () => child.kill()
274
+ req.signal?.addEventListener('abort', abortHandler, { once: true })
275
+
276
+ child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
277
+ child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
278
+ child.on('error', reject)
279
+ child.on('close', (exitCode, signal) => {
280
+ req.signal?.removeEventListener('abort', abortHandler)
281
+ resolve({
282
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
283
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
284
+ exitCode,
285
+ signal,
286
+ })
287
+ })
288
+ return promise
289
+ },
290
+ }
291
+
292
+ function hasOwn<T extends object, K extends PropertyKey>(
293
+ value: T | undefined,
294
+ key: K,
295
+ ): value is T & Record<K, unknown> {
296
+ return !!value && Object.prototype.hasOwnProperty.call(value, key)
297
+ }
298
+
299
+ function isRecord(value: unknown): value is Record<string, unknown> {
300
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
301
+ }
302
+
303
+ function ompString(value: unknown): string {
304
+ return typeof value === 'string' ? value : ''
305
+ }
306
+
307
+ function firstNonEmpty(...values: string[]): string {
308
+ for (const value of values) {
309
+ const trimmed = value.trim()
310
+ if (trimmed) return trimmed
311
+ }
312
+ return ''
313
+ }
package/src/pipeline.ts CHANGED
@@ -30,12 +30,13 @@ export class Pipeline<S extends BaseState> {
30
30
  this.ctx = {
31
31
  sessionId: undefined,
32
32
  defaultModel: config.defaultModel,
33
- defaultAgent: config.defaultAgent,
34
33
  timeoutSec: config.timeoutSec ?? 3600,
35
34
  workdir: config.workdir,
35
+ opencode: config.opencode,
36
36
  claudeCode: config.claudeCode,
37
37
  codex: config.codex,
38
38
  pi: config.pi,
39
+ omp: config.omp,
39
40
  }
40
41
  }
41
42
 
@@ -88,8 +89,8 @@ export class Pipeline<S extends BaseState> {
88
89
  result: result as StepResult<unknown>,
89
90
  })
90
91
 
91
- // Update opencode session in state
92
- this.state.opencodeSessionId = this.ctx.sessionId
92
+ // Update backend session in state.
93
+ this.state.agentSessionId = this.ctx.sessionId
93
94
 
94
95
  await this.saveCheckpoint()
95
96
  return result
@@ -158,7 +159,7 @@ export class Pipeline<S extends BaseState> {
158
159
  }
159
160
  }
160
161
 
161
- /** getSessionId returns the current OpenCode session ID. */
162
+ /** getSessionId returns the current backend session ID. */
162
163
  getSessionId(): string | undefined {
163
164
  return this.ctx.sessionId
164
165
  }
@@ -188,9 +189,9 @@ export class Pipeline<S extends BaseState> {
188
189
  const pipeline = new Pipeline<S>(config, () => state)
189
190
  pipeline.state = state
190
191
 
191
- // Restore context from state
192
- if (state.opencodeSessionId) {
193
- pipeline.ctx.sessionId = state.opencodeSessionId
192
+ // Restore context from state.
193
+ if (state.agentSessionId) {
194
+ pipeline.ctx.sessionId = state.agentSessionId
194
195
  }
195
196
 
196
197
  // Restore step number
package/src/predict.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ocpipe Predict class.
3
3
  *
4
- * Executes a signature by generating a prompt, calling OpenCode, and parsing the response.
4
+ * Executes a signature by generating a prompt, calling a backend, and parsing
5
+ * the response.
5
6
  */
6
7
 
7
8
  import { z } from 'zod/v4'
@@ -37,8 +38,6 @@ import {
37
38
 
38
39
  /** Configuration for a Predict instance. */
39
40
  export interface PredictConfig {
40
- /** Override the pipeline's default agent. */
41
- agent?: string
42
41
  /** Override the pipeline's default model. */
43
42
  model?: ModelConfig
44
43
  /** Start a fresh session (default: false, reuses context). */
@@ -71,14 +70,15 @@ export class Predict<S extends AnySignature> {
71
70
 
72
71
  const agentResult = await runAgent({
73
72
  prompt,
74
- agent: this.config.agent ?? ctx.defaultAgent,
75
73
  model: this.config.model ?? ctx.defaultModel,
76
74
  sessionId: this.config.newSession ? undefined : ctx.sessionId,
77
75
  timeoutSec: ctx.timeoutSec,
78
76
  workdir: ctx.workdir,
79
77
  claudeCode: ctx.claudeCode,
78
+ opencode: ctx.opencode,
80
79
  codex: ctx.codex,
81
80
  pi: ctx.pi,
81
+ omp: ctx.omp,
82
82
  signal: ctx.signal,
83
83
  })
84
84
 
@@ -205,12 +205,13 @@ export class Predict<S extends AnySignature> {
205
205
  prompt: repairPrompt,
206
206
  model: correctionModel ?? ctx.defaultModel,
207
207
  sessionId: correctionModel ? undefined : sessionId,
208
- agent: ctx.defaultAgent,
209
208
  timeoutSec: ctx.timeoutSec,
210
209
  workdir: ctx.workdir,
211
210
  claudeCode: ctx.claudeCode,
211
+ opencode: ctx.opencode,
212
212
  codex: ctx.codex,
213
213
  pi: ctx.pi,
214
+ omp: ctx.omp,
214
215
  signal: ctx.signal,
215
216
  })
216
217
 
@@ -284,12 +285,13 @@ export class Predict<S extends AnySignature> {
284
285
  prompt: patchPrompt,
285
286
  model: correctionModel ?? ctx.defaultModel,
286
287
  sessionId: correctionModel ? undefined : sessionId,
287
- agent: ctx.defaultAgent,
288
288
  timeoutSec: ctx.timeoutSec,
289
289
  workdir: ctx.workdir,
290
290
  claudeCode: ctx.claudeCode,
291
+ opencode: ctx.opencode,
291
292
  codex: ctx.codex,
292
293
  pi: ctx.pi,
294
+ omp: ctx.omp,
293
295
  signal: ctx.signal,
294
296
  })
295
297
 
@@ -396,8 +398,7 @@ export class Predict<S extends AnySignature> {
396
398
  // Add field descriptions from our config (toJSONSchema uses .describe() metadata)
397
399
  // Since our FieldConfig has a separate desc field, merge it in
398
400
  const props = jsonSchema.properties as
399
- | Record<string, Record<string, unknown>>
400
- | undefined
401
+ Record<string, Record<string, unknown>> | undefined
401
402
  if (props) {
402
403
  for (const [name, config] of Object.entries(this.sig.outputs) as [
403
404
  string,
package/src/testing.ts CHANGED
@@ -134,18 +134,20 @@ export class MockAgentBackend {
134
134
  export function createMockContext(
135
135
  overrides?: Partial<{
136
136
  sessionId: string
137
- defaultModel: { providerID: string; modelID: string }
138
- defaultAgent: string
137
+ defaultModel: {
138
+ backend?: 'claude-code' | 'codex' | 'pi' | 'omp'
139
+ providerID?: string
140
+ modelID: string
141
+ }
139
142
  timeoutSec: number
140
143
  }>,
141
144
  ) {
142
145
  return {
143
146
  sessionId: overrides?.sessionId,
144
147
  defaultModel: overrides?.defaultModel ?? {
145
- providerID: 'github-copilot',
146
- modelID: 'grok-code-fast-1',
148
+ backend: 'omp',
149
+ modelID: 'gpt-5.5',
147
150
  },
148
- defaultAgent: overrides?.defaultAgent ?? 'general',
149
151
  timeoutSec: overrides?.timeoutSec ?? 60,
150
152
  }
151
153
  }