opencode-drive 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 (45) hide show
  1. package/README.md +90 -0
  2. package/bin/opencode-drive +2 -0
  3. package/package.json +45 -0
  4. package/src/cli/commands.ts +175 -0
  5. package/src/cli/describe.ts +14 -0
  6. package/src/cli/driver-runner.ts +39 -0
  7. package/src/cli/driver.ts +28 -0
  8. package/src/cli/index.ts +158 -0
  9. package/src/cli/instance.ts +218 -0
  10. package/src/cli/parse.ts +24 -0
  11. package/src/cli/registry.ts +120 -0
  12. package/src/cli/send.ts +48 -0
  13. package/src/cli/start.ts +56 -0
  14. package/src/cli/types.ts +46 -0
  15. package/src/client/backend.ts +184 -0
  16. package/src/client/client.ts +252 -0
  17. package/src/client/index.ts +17 -0
  18. package/src/client/protocol.ts +186 -0
  19. package/src/experimental/campaign-api.ts +34 -0
  20. package/src/experimental/campaign.ts +144 -0
  21. package/src/experimental/cli-campaign.ts +179 -0
  22. package/src/experimental/drive.ts +37 -0
  23. package/src/experimental/driver.ts +41 -0
  24. package/src/experimental/flow-driver.ts +189 -0
  25. package/src/experimental/flows/generate.ts +278 -0
  26. package/src/experimental/flows/index.ts +6 -0
  27. package/src/experimental/flows/properties.ts +51 -0
  28. package/src/experimental/flows/types.ts +47 -0
  29. package/src/experimental/flows/weights.ts +198 -0
  30. package/src/experimental/generators/config.ts +240 -0
  31. package/src/experimental/generators/filesystem.ts +95 -0
  32. package/src/experimental/generators/generate.ts +32 -0
  33. package/src/experimental/generators/index.ts +10 -0
  34. package/src/experimental/generators/initial-state.ts +37 -0
  35. package/src/experimental/generators/random.ts +35 -0
  36. package/src/experimental/hello-driver.ts +42 -0
  37. package/src/experimental/index.ts +3 -0
  38. package/src/experimental/model/derive.ts +119 -0
  39. package/src/experimental/model/index.ts +2 -0
  40. package/src/experimental/model/model.ts +42 -0
  41. package/src/experimental/opencode-simulation.ts +1 -0
  42. package/src/experimental/reproduce-stale-running-visible.ts +60 -0
  43. package/src/experimental/reproduce-stale-running.ts +26 -0
  44. package/src/experimental/stale-running-driver.ts +74 -0
  45. package/src/index.ts +1 -0
@@ -0,0 +1,278 @@
1
+ import { createRng } from "../generators/random.js"
2
+ import { defaultWeights, pickWeighted, type GenerationWeights } from "./weights.js"
3
+ import type { FlowResponse, FlowScenario, FlowTurn, InteractionKind, ResponseKind } from "./types.js"
4
+
5
+ const tasks = [
6
+ {
7
+ subject: "the request routing path",
8
+ target: "src/server.ts",
9
+ symbol: "createServer",
10
+ concern: "a request can be dispatched after shutdown begins",
11
+ },
12
+ {
13
+ subject: "the cache invalidation behavior",
14
+ target: "src/cache.ts",
15
+ symbol: "invalidate",
16
+ concern: "stale entries survive a failed refresh",
17
+ },
18
+ {
19
+ subject: "the configuration loading path",
20
+ target: "src/config.ts",
21
+ symbol: "loadConfig",
22
+ concern: "defaults are applied before validation",
23
+ },
24
+ {
25
+ subject: "the greeting helper",
26
+ target: "src/example.ts",
27
+ symbol: "greet",
28
+ concern: "empty names produce awkward output",
29
+ },
30
+ ] as const
31
+
32
+ const followUps = [
33
+ "Keep the change narrow and call out any assumptions.",
34
+ "Check the edge cases before proposing a change.",
35
+ "Prefer evidence from the repository over guesses.",
36
+ "Explain what should be tested when you are done.",
37
+ ] as const
38
+
39
+ const split = (text: string, pieces: number) => {
40
+ const size = Math.ceil(text.length / pieces)
41
+ return Array.from({ length: pieces }, (_, index) => text.slice(index * size, (index + 1) * size)).filter(Boolean)
42
+ }
43
+
44
+ export function generateFlow(seed: number, options?: { readonly turns?: number; readonly weights?: GenerationWeights; readonly enabledTools?: Set<keyof GenerationWeights["toolSelection"]> }): FlowScenario {
45
+ const rng = createRng(seed)
46
+ const weights = options?.weights ?? defaultWeights
47
+ const task = rng.pick(tasks)
48
+ const count = options?.turns ?? rng.int(6, 9)
49
+
50
+ if (options?.enabledTools && options.enabledTools.size > 0) {
51
+ }
52
+
53
+ // Use weights to select response kinds for each turn
54
+ const responseKindKeys: readonly ResponseKind[] = ["text", "chunked", "reasoning", "markdown", "raw", "tool"]
55
+ const responseKindWeights = responseKindKeys.map(k => weights.responseKinds[k])
56
+ const selected: ResponseKind[] = Array.from({ length: count }, () =>
57
+ pickWeighted(responseKindKeys, responseKindWeights, rng.next())
58
+ )
59
+
60
+ const turns = selected.map((kind, index) => makeTurn(seed, index, kind, task, rng.pick(followUps), rng, weights, options?.enabledTools))
61
+ const responseKinds = { text: 0, chunked: 0, reasoning: 0, markdown: 0, raw: 0, tool: 0 }
62
+ const toolNames: Record<string, number> = {}
63
+ const interactions = { normal: 0, "double-submit": 0, steer: 0, interrupt: 0, "provider-drop": 0 }
64
+ const streamChunkTypes = new Set<string>()
65
+ for (const turn of turns) {
66
+ interactions[turn.interaction]++
67
+ for (const response of turn.responses) {
68
+ responseKinds[response.kind]++
69
+ for (const name of response.toolNames ?? []) toolNames[name] = (toolNames[name] ?? 0) + 1
70
+ for (const type of response.streamChunkTypes ?? []) streamChunkTypes.add(type)
71
+ for (const item of response.chunks.flat()) {
72
+ streamChunkTypes.add(item.type)
73
+ }
74
+ }
75
+ }
76
+ return {
77
+ version: 1,
78
+ seed,
79
+ name: `${task.symbol}-${seed.toString(36)}`,
80
+ turns,
81
+ coverage: {
82
+ responseKinds,
83
+ toolNames,
84
+ interactions,
85
+ streamChunkTypes: [...streamChunkTypes],
86
+ providerExchanges: turns.reduce((total, turn) => total + turn.responses.length, 0),
87
+ },
88
+ }
89
+ }
90
+
91
+ function makeTurn(
92
+ seed: number,
93
+ index: number,
94
+ kind: ResponseKind,
95
+ task: (typeof tasks)[number],
96
+ followUp: string,
97
+ rng: ReturnType<typeof createRng>,
98
+ weights: GenerationWeights,
99
+ enabledTools?: Set<keyof GenerationWeights["toolSelection"]>,
100
+ ): FlowTurn {
101
+ const marker = `[flow-${seed}-turn-${index + 1}-complete]`
102
+
103
+ // Use weights to select interaction kind
104
+ const interactionKeys: readonly InteractionKind[] = ["normal", "double-submit", "steer", "interrupt", "provider-drop"]
105
+ const interactionWeights = interactionKeys.map(k => weights.interactions[k])
106
+ const interaction: InteractionKind = kind === "raw"
107
+ ? "provider-drop"
108
+ : kind === "tool"
109
+ ? (seed % 2 === 0 ? "normal" : "interrupt")
110
+ : pickWeighted(interactionKeys, interactionWeights, rng.next())
111
+
112
+ const prompts = [
113
+ `Orient yourself in this project and identify where ${task.subject} lives. ${followUp}`,
114
+ `Inspect ${task.target} and trace ${task.symbol} through its callers. What behavior is observable?`,
115
+ `The suspected bug is that ${task.concern}. Find evidence for or against that hypothesis.`,
116
+ "Compare the smallest plausible fixes. Which one preserves existing behavior best?",
117
+ "Describe the implementation in enough detail that another engineer could apply it safely.",
118
+ "Now challenge that approach with one failure scenario and revise it if necessary.",
119
+ "Give me a focused test plan covering the happy path, boundary behavior, and regression risk.",
120
+ "Review the whole investigation for contradictions or unsupported claims.",
121
+ "Summarize the final recommendation and the next concrete action.",
122
+ ]
123
+ return {
124
+ prompt: prompts[index % prompts.length]!,
125
+ marker,
126
+ interaction,
127
+ ...(interaction === "steer" ? { steerPrompt: `While that is running, also check the concurrency boundary for turn ${index + 1}.` } : {}),
128
+ responses: [
129
+ ...makeResponses(kind, marker, task, index, rng, weights, enabledTools),
130
+ ...(interaction === "steer"
131
+ ? [{ kind: "text" as const, chunks: [[{ type: "textDelta" as const, text: `I incorporated the in-flight steering prompt. ${marker}` }]], finish: "stop" as const }]
132
+ : []),
133
+ ],
134
+ }
135
+ }
136
+
137
+ function makeResponses(
138
+ kind: ResponseKind,
139
+ marker: string,
140
+ task: (typeof tasks)[number],
141
+ index: number,
142
+ rng: ReturnType<typeof createRng>,
143
+ weights: GenerationWeights,
144
+ enabledTools?: Set<keyof GenerationWeights["toolSelection"]>,
145
+ ): ReadonlyArray<FlowResponse> {
146
+ const conclusion = `Turn ${index + 1}: the evidence keeps the investigation focused on \`${task.symbol}\`. ${marker}`
147
+ if (kind === "text") return [{ kind, chunks: [[{ type: "textDelta", text: conclusion }]], finish: "stop" }]
148
+ if (kind === "chunked") {
149
+ return [{ kind, chunks: split(`${conclusion} I will preserve the current API and verify the boundary explicitly.`, 4).map((text) => [{ type: "textDelta", text }] as const), finish: "stop" }]
150
+ }
151
+ if (kind === "reasoning") {
152
+ return [{
153
+ kind,
154
+ chunks: [
155
+ [{ type: "reasoningDelta", text: `I need to separate observed behavior from the hypothesis about ${task.concern}.` }],
156
+ [{ type: "textDelta", text: conclusion }],
157
+ ],
158
+ finish: "stop",
159
+ }]
160
+ }
161
+ if (kind === "markdown") {
162
+ return [{
163
+ kind,
164
+ chunks: [[{
165
+ type: "textDelta",
166
+ text: `### Findings\n\n- Target: \`${task.target}\`\n- Symbol: \`${task.symbol}\`\n- Risk: ${task.concern}\n\n\`\`\`ts\n// Preserve the existing contract; test the boundary first.\n\`\`\`\n\n${marker}`,
167
+ }]],
168
+ finish: "stop",
169
+ }]
170
+ }
171
+ if (kind === "raw") {
172
+ // Use weights to decide whether to generate a failure at all
173
+ const failureTotal = weights.failures.disconnect +
174
+ weights.failures.invalidProviderEvent +
175
+ weights.failures.disconnectDuringToolInput +
176
+ weights.failures.disconnectAfterTools
177
+ const hasAnyFailure = failureTotal > 0 && rng.next() < (failureTotal / (failureTotal + 1))
178
+
179
+ if (!hasAnyFailure) {
180
+ // Normal raw response, no failures
181
+ return [{
182
+ kind,
183
+ chunks: [
184
+ [{ type: "raw", chunk: { id: `chatcmpl-${index}`, object: "chat.completion.chunk", created: 1, model: "sim-model", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] } }],
185
+ [{ type: "raw", chunk: { choices: [] } }],
186
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null, reasoning_content: "Checking the stream state. " }, finish_reason: null }] } }],
187
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null }, finish_reason: null }] } }],
188
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: conclusion }, finish_reason: null }] } }],
189
+ [{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144, prompt_tokens_details: { cached_tokens: 20 }, completion_tokens_details: { reasoning_tokens: 5 } } } }],
190
+ ],
191
+ finish: (["stop", "length", "content-filter"] as const)[index % 3]!,
192
+ streamChunkTypes: [
193
+ "chat.metadata-role",
194
+ "chat.empty-choices",
195
+ "chat.reasoning-content",
196
+ "chat.content-null",
197
+ "chat.content-delta",
198
+ "chat.usage",
199
+ `chat.finish.${(["stop", "length", "content-filter"] as const)[index % 3]!}`,
200
+ ],
201
+ }]
202
+ }
203
+
204
+ // Pick which failure mode to use
205
+ const failureKeys: readonly (keyof typeof weights.failures)[] = ["disconnect", "invalidProviderEvent", "disconnectDuringToolInput", "disconnectAfterTools"]
206
+ const failureWeights = failureKeys.map(k => weights.failures[k])
207
+ const failureType = pickWeighted(failureKeys, failureWeights, rng.next())
208
+
209
+ const regularDisconnect = failureType === "disconnect"
210
+ const invalidEvent = failureType === "invalidProviderEvent"
211
+
212
+ return [{
213
+ kind,
214
+ chunks: [
215
+ [{ type: "raw", chunk: { id: `chatcmpl-${index}`, object: "chat.completion.chunk", created: 1, model: "sim-model", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] } }],
216
+ [{ type: "raw", chunk: { choices: [] } }],
217
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null, reasoning_content: "Checking the stream state. " }, finish_reason: null }] } }],
218
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: null }, finish_reason: null }] } }],
219
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { content: conclusion }, finish_reason: null }] } }],
220
+ [{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144, prompt_tokens_details: { cached_tokens: 20 }, completion_tokens_details: { reasoning_tokens: 5 } } } }],
221
+ ...(invalidEvent ? [[{ type: "raw" as const, chunk: { choices: "detached" } }]] : []),
222
+ ],
223
+ finish: (["stop", "length", "content-filter"] as const)[index % 3]!,
224
+ ...(regularDisconnect ? { terminal: "disconnect" as const } : invalidEvent ? { terminal: "invalid-provider-event" as const } : {}),
225
+ streamChunkTypes: [
226
+ "chat.metadata-role",
227
+ "chat.empty-choices",
228
+ "chat.reasoning-content",
229
+ "chat.content-null",
230
+ "chat.content-delta",
231
+ "chat.usage",
232
+ `chat.finish.${(["stop", "length", "content-filter"] as const)[index % 3]!}`,
233
+ ...(invalidEvent ? ["chat.invalid-provider-event"] : []),
234
+ ...(regularDisconnect ? ["chat.transport-disconnect"] : []),
235
+ ],
236
+ }]
237
+ }
238
+
239
+ // Tool response - keep it simple like the original, but filter tools
240
+ const baseTools = [
241
+ { name: "glob" as const, input: { pattern: "src/**/*.ts" } },
242
+ { name: "grep" as const, input: { pattern: task.symbol, path: "src", include: "*.ts" } },
243
+ { name: "read" as const, input: { path: task.target, limit: 120 } },
244
+ { name: "todowrite" as const, input: { todos: [{ content: "Reproduce detached loading state", status: "in_progress", priority: "high" }] } },
245
+ ]
246
+
247
+ // Filter to only enabled tools
248
+ const availableTools = enabledTools
249
+ ? baseTools.filter(t => enabledTools.has(t.name))
250
+ : baseTools
251
+
252
+ if (availableTools.length === 0) {
253
+ // No tools enabled, return text response instead
254
+ return [{ kind: "text", chunks: [[{ type: "textDelta", text: conclusion }]], finish: "stop" }]
255
+ }
256
+
257
+ const tool = availableTools[index % availableTools.length]!
258
+ return [
259
+ {
260
+ kind,
261
+ chunks: [
262
+ [{ type: "reasoningDelta", text: "I should inspect the repository before making a claim." }],
263
+ [{ type: "textDelta", text: `I'll check ${task.target} and related code.` }],
264
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `call-${marker.slice(1, -1)}`, function: { name: tool.name, arguments: "{" } }] }, finish_reason: null }] } }],
265
+ [{ type: "raw", chunk: { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(tool.input).slice(1) } }] }, finish_reason: null }] } }],
266
+ [{ type: "raw", chunk: { choices: [], usage: { prompt_tokens: 180, completion_tokens: 30, total_tokens: 210 } } }],
267
+ ],
268
+ finish: "tool-calls",
269
+ toolNames: [tool.name],
270
+ streamChunkTypes: ["chat.tool-call-start", "chat.tool-call-arguments", "chat.usage", "chat.finish.tool-calls"],
271
+ },
272
+ {
273
+ kind: "text",
274
+ chunks: [[{ type: "textDelta", text: `The repository observation is now part of the evidence. ${conclusion}` }]],
275
+ finish: "stop",
276
+ },
277
+ ]
278
+ }
@@ -0,0 +1,6 @@
1
+ export { generateFlow } from "./generate.js"
2
+ export { defineProperty, flowProperties, isRunning } from "./properties.js"
3
+ export type { FlowProperty, FlowPropertyContext, TurnOutcome } from "./properties.js"
4
+ export type { FlowResponse, FlowResult, FlowScenario, FlowTurn, InteractionKind, ResponseKind } from "./types.js"
5
+ export { defaultWeights, parseWeightsFromArgs, pickWeighted, shouldGenerate } from "./weights.js"
6
+ export type { GenerationWeights } from "./weights.js"
@@ -0,0 +1,51 @@
1
+ import type { BackendSimulationClient, SimulationClient, UiState } from "../../client/index.js"
2
+ import type { FlowTurn } from "./types.js"
3
+
4
+ export type TurnOutcome = "completed" | "interrupted" | "provider-error"
5
+
6
+ export interface FlowPropertyContext {
7
+ readonly turn: FlowTurn
8
+ readonly ui: SimulationClient
9
+ readonly backend: BackendSimulationClient
10
+ readonly outcome?: TurnOutcome
11
+ readonly waitFor: (label: string, check: () => Promise<boolean>) => Promise<void>
12
+ }
13
+
14
+ export interface FlowProperty {
15
+ readonly name: string
16
+ readonly afterSubmit?: (context: FlowPropertyContext) => Promise<void>
17
+ readonly afterTerminal?: (context: FlowPropertyContext) => Promise<void>
18
+ }
19
+
20
+ export const defineProperty = (property: FlowProperty) => property
21
+
22
+ export const flowProperties: ReadonlyArray<FlowProperty> = [
23
+ defineProperty({
24
+ name: "submitted-turn-shows-running",
25
+ afterSubmit: (context) => {
26
+ if (context.turn.responses.some((response) => response.terminal === "invalid-provider-event" || response.terminal === "disconnect")) return Promise.resolve()
27
+ return context.waitFor("submitted turn to show running", async () => isRunning(await context.ui.state()))
28
+ },
29
+ }),
30
+ defineProperty({
31
+ name: "turn-reaches-terminal-outcome",
32
+ afterTerminal: async (context) => {
33
+ if (context.outcome === undefined) throw new Error("turn has no terminal outcome")
34
+ await context.waitFor(
35
+ "terminal turn to have no provider exchange",
36
+ async () => (await context.backend.pendingExchanges()).exchanges.length === 0,
37
+ )
38
+ },
39
+ }),
40
+ defineProperty({
41
+ name: "terminal-turn-clears-running",
42
+ afterTerminal: (context) =>
43
+ context.waitFor("terminal turn to stop showing running", async () => {
44
+ return !isRunning(await context.ui.state())
45
+ }),
46
+ }),
47
+ ]
48
+
49
+ export function isRunning(state: UiState) {
50
+ return !state.focused.editor
51
+ }
@@ -0,0 +1,47 @@
1
+ import type { BackendFinishReason, BackendItem, UiState } from "../../client/index.js"
2
+
3
+ export type ResponseKind = "text" | "chunked" | "reasoning" | "markdown" | "raw" | "tool"
4
+ export type InteractionKind = "normal" | "double-submit" | "steer" | "interrupt" | "provider-drop"
5
+
6
+ export interface FlowResponse {
7
+ readonly kind: ResponseKind
8
+ readonly chunks: ReadonlyArray<ReadonlyArray<BackendItem>>
9
+ readonly finish: BackendFinishReason
10
+ readonly toolNames?: ReadonlyArray<string>
11
+ readonly terminal?: "finish" | "invalid-provider-event" | "disconnect"
12
+ readonly streamChunkTypes?: ReadonlyArray<string>
13
+ }
14
+
15
+ export interface FlowTurn {
16
+ readonly prompt: string
17
+ readonly marker: string
18
+ readonly interaction: InteractionKind
19
+ readonly steerPrompt?: string
20
+ readonly responses: ReadonlyArray<FlowResponse>
21
+ }
22
+
23
+ export interface FlowScenario {
24
+ readonly version: 1
25
+ readonly seed: number
26
+ readonly name: string
27
+ readonly turns: ReadonlyArray<FlowTurn>
28
+ readonly coverage: {
29
+ readonly responseKinds: Readonly<Record<ResponseKind, number>>
30
+ readonly toolNames: Readonly<Record<string, number>>
31
+ readonly interactions: Readonly<Record<InteractionKind, number>>
32
+ readonly streamChunkTypes: ReadonlyArray<string>
33
+ readonly providerExchanges: number
34
+ }
35
+ }
36
+
37
+ export interface FlowResult {
38
+ readonly seed: number
39
+ readonly name: string
40
+ readonly turns: number
41
+ readonly assistantExchanges: number
42
+ readonly subagentExchanges: number
43
+ readonly titleExchanges: number
44
+ readonly traceRecords: number
45
+ readonly durationMs: number
46
+ readonly finalState: UiState
47
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Weighted generation config for controlling what kinds of scenarios are generated.
3
+ * All weights are relative; they control probability distribution.
4
+ */
5
+
6
+ export interface GenerationWeights {
7
+ // Response types
8
+ responseKinds: {
9
+ text: number
10
+ chunked: number
11
+ reasoning: number
12
+ markdown: number
13
+ raw: number
14
+ tool: number
15
+ }
16
+
17
+ // Interaction patterns
18
+ interactions: {
19
+ normal: number
20
+ "double-submit": number
21
+ steer: number
22
+ interrupt: number
23
+ "provider-drop": number
24
+ }
25
+
26
+ // Failure modes (for raw responses)
27
+ failures: {
28
+ invalidProviderEvent: number // malformed streaming event
29
+ disconnect: number // abrupt connection drop
30
+ disconnectDuringToolInput: number // drop mid-tool-call
31
+ disconnectAfterTools: number // drop after tool submission
32
+ }
33
+
34
+ // Tools to generate
35
+ toolSelection: {
36
+ glob: number
37
+ grep: number
38
+ read: number
39
+ todowrite: number
40
+ shell: number
41
+ write: number
42
+ edit: number
43
+ apply_patch: number
44
+ webfetch: number
45
+ websearch: number
46
+ skill: number
47
+ subagent: number
48
+ question: number
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Default weights: balanced distribution across all options
54
+ */
55
+ export const defaultWeights: GenerationWeights = {
56
+ responseKinds: {
57
+ text: 1,
58
+ chunked: 1,
59
+ reasoning: 1,
60
+ markdown: 1,
61
+ raw: 1,
62
+ tool: 1,
63
+ },
64
+ interactions: {
65
+ normal: 3,
66
+ "double-submit": 1,
67
+ steer: 1,
68
+ interrupt: 1,
69
+ "provider-drop": 1,
70
+ },
71
+ failures: {
72
+ invalidProviderEvent: 1,
73
+ disconnect: 1,
74
+ disconnectDuringToolInput: 1,
75
+ disconnectAfterTools: 1,
76
+ },
77
+ toolSelection: {
78
+ glob: 1,
79
+ grep: 1,
80
+ read: 1,
81
+ todowrite: 1,
82
+ shell: 1,
83
+ write: 1,
84
+ edit: 1,
85
+ apply_patch: 1,
86
+ webfetch: 1,
87
+ websearch: 1,
88
+ skill: 1,
89
+ subagent: 1,
90
+ question: 1,
91
+ },
92
+ }
93
+
94
+ /**
95
+ * Parse weights from CLI flags like --weight-text=5 --weight-disconnect=10
96
+ * Format: --weight-<key>=<number>
97
+ * Keys can use dashes or underscores (both map to the same key)
98
+ */
99
+ export function parseWeightsFromArgs(args: string[]): GenerationWeights {
100
+ const weights = structuredClone(defaultWeights)
101
+ const keyMap = new Map<string, [category: keyof GenerationWeights, key: string]>()
102
+
103
+ // Build map of all known keys
104
+ const typedWeights = weights as Record<keyof GenerationWeights, Record<string, number>>
105
+ Object.entries(typedWeights).forEach(([category, items]) => {
106
+ if (typeof items === "object" && !Array.isArray(items)) {
107
+ Object.keys(items).forEach((key) => {
108
+ keyMap.set(key, [category as keyof GenerationWeights, key])
109
+ keyMap.set(key.replace(/_/g, "-"), [category as keyof GenerationWeights, key])
110
+ })
111
+ }
112
+ })
113
+
114
+ for (const arg of args) {
115
+ if (!arg.startsWith("--weight-")) continue
116
+ const [keyStr, valueStr] = arg.slice(9).split("=")
117
+ if (!keyStr || !valueStr) continue
118
+
119
+ const value = parseFloat(valueStr)
120
+ if (!isFinite(value) || value < 0) {
121
+ console.warn(`Invalid weight value for --weight-${keyStr}: ${valueStr}`)
122
+ continue
123
+ }
124
+
125
+ const found = keyMap.get(keyStr)
126
+ if (!found) {
127
+ console.warn(`Unknown weight key: --weight-${keyStr}`)
128
+ continue
129
+ }
130
+
131
+ const [category, key] = found
132
+ typedWeights[category][key] = value
133
+ }
134
+
135
+ return weights
136
+ }
137
+
138
+ /**
139
+ * Pick from weighted options using an RNG
140
+ */
141
+ export function pickWeighted<T extends readonly string[]>(
142
+ options: T,
143
+ weights: readonly number[],
144
+ random: number,
145
+ ): T[number] {
146
+ const total = weights.reduce((sum, w) => sum + w, 0)
147
+ let accumulated = 0
148
+ const target = random * total
149
+
150
+ for (let i = 0; i < options.length; i++) {
151
+ accumulated += weights[i]!
152
+ if (target <= accumulated) return options[i]!
153
+ }
154
+
155
+ return options[options.length - 1]!
156
+ }
157
+
158
+ /**
159
+ * Get weighted probability for a single option from a set
160
+ */
161
+ export function shouldGenerate(weight: number, total: number, random: number): boolean {
162
+ return random < weight / total
163
+ }
164
+
165
+ /**
166
+ * Filter tools based on enabled set from CLI
167
+ * Usage: --tools=glob,grep,read or --tools glob,grep,read
168
+ */
169
+ export function parseEnabledTools(args: string[]): Set<keyof GenerationWeights["toolSelection"]> | null {
170
+ // Look for --tools= format
171
+ let toolsArg = args.find(arg => arg.startsWith("--tools="))?.slice(8)
172
+
173
+ // Look for --tools <value> format
174
+ if (!toolsArg) {
175
+ const toolsIndex = args.indexOf("--tools")
176
+ if (toolsIndex !== -1 && toolsIndex + 1 < args.length) {
177
+ toolsArg = args[toolsIndex + 1]
178
+ }
179
+ }
180
+
181
+ if (!toolsArg) return null
182
+
183
+ const toolList = toolsArg.split(",").map(t => t.trim()).filter(t => t.length > 0)
184
+ if (toolList.length === 0) return null
185
+
186
+ const validTools: Set<keyof GenerationWeights["toolSelection"]> = new Set()
187
+ const allTools = new Set(Object.keys(defaultWeights.toolSelection) as Array<keyof GenerationWeights["toolSelection"]>)
188
+
189
+ for (const tool of toolList) {
190
+ if (allTools.has(tool as keyof GenerationWeights["toolSelection"])) {
191
+ validTools.add(tool as keyof GenerationWeights["toolSelection"])
192
+ } else {
193
+ console.warn(`Unknown tool: ${tool}`)
194
+ }
195
+ }
196
+
197
+ return validTools.size > 0 ? validTools : null
198
+ }