opencode-codeops 1.9.0 → 1.10.1

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.
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Adaptive reasoning-effort helper for CodeOps sessions.
4
+ *
5
+ * The plugin needs one place to answer a simple question per request: "which
6
+ * reasoning level should this request use, if any?" This module answers it
7
+ * without any framework dependency so `node --test` can exercise every edge:
8
+ *
9
+ * - marker scanning over message text parts;
10
+ * - source precedence (dispatch marker, session flag, routing default);
11
+ * - routing-config lookup with hostile-shape tolerance;
12
+ * - provider-option application through the model's own variant record; and
13
+ * - reading the per-session state file written by the skills.
14
+ *
15
+ * Every function is deliberately total: malformed input yields "no override",
16
+ * never a thrown error. The plugin hooks sit on the request path, where a
17
+ * crash would break the user's session, so safety beats strictness here.
18
+ *
19
+ * @module lib/reasoning-effort
20
+ */
21
+
22
+ import { appendFileSync, lstatSync, readFileSync } from "node:fs"
23
+ import { join } from "node:path"
24
+
25
+ import { ensureSessionTmpDir, sessionTmpDir } from "./tmp-hygiene.mjs"
26
+
27
+ /**
28
+ * The four levels a plan or skill may suggest.
29
+ *
30
+ * These are the only values accepted in dispatch markers, session flags, and
31
+ * plan suggestions. Routing policy is project configuration and may name the
32
+ * wider provider enum instead.
33
+ */
34
+ export const EFFORT_LEVELS = Object.freeze(["low", "medium", "high", "max"])
35
+
36
+ /**
37
+ * The reasoning values accepted by `routing.roles.<agent>.reasoning` in the
38
+ * project configuration.
39
+ *
40
+ * The list mirrors the CodeOps config schema, which passes provider-native
41
+ * values through so a project can ask for `minimal` or `xhigh` even though the
42
+ * suggestion vocabulary only offers the four common levels.
43
+ */
44
+ export const ROUTING_REASONING_VALUES = Object.freeze([
45
+ "none",
46
+ "minimal",
47
+ "low",
48
+ "medium",
49
+ "high",
50
+ "xhigh",
51
+ "max",
52
+ ])
53
+
54
+ /**
55
+ * Largest state file accepted by {@link readSessionEffort}.
56
+ *
57
+ * The real file is a few dozen bytes. The cap keeps a hostile or corrupt file
58
+ * from being slurped into memory if the temp directory is ever tampered with.
59
+ */
60
+ const MAX_STATE_FILE_BYTES = 4096
61
+
62
+ /**
63
+ * One standalone marker line, anchored to the whole line.
64
+ *
65
+ * The marker is intentionally different from the human-readable plan line
66
+ * `> **Reasoning**: ...`, so quoting a plan can never act as a machine
67
+ * directive. Whitespace inside the brackets is tolerated; text before or after
68
+ * is not.
69
+ */
70
+ const MARKER_PATTERN = /^\[codeops-effort:\s*(low|medium|high|max)\]\s*$/
71
+
72
+ /**
73
+ * Check whether a value is one of the four suggestion levels.
74
+ *
75
+ * @param value - Value to inspect
76
+ * @returns True only for `"low"`, `"medium"`, `"high"`, or `"max"`
77
+ *
78
+ * @example
79
+ * isEffortLevel("high") // true
80
+ * isEffortLevel("xhigh") // false
81
+ */
82
+ export function isEffortLevel(value) {
83
+ return typeof value === "string" && EFFORT_LEVELS.includes(value)
84
+ }
85
+
86
+ /**
87
+ * Check whether a value is a valid routing reasoning entry.
88
+ *
89
+ * @param value - Value to inspect
90
+ * @returns True for any value in the routing enum
91
+ */
92
+ export function isRoutingReasoning(value) {
93
+ return typeof value === "string" && ROUTING_REASONING_VALUES.includes(value)
94
+ }
95
+
96
+ /**
97
+ * Find the first valid dispatch marker across a list of message text parts.
98
+ *
99
+ * Only string entries are inspected; non-string entries (tool parts, images,
100
+ * malformed structures) are skipped so a hostile part can never break the
101
+ * request path. Lines are split on `\n`, `\r\n`, and `\r`, then trimmed before
102
+ * the anchored pattern is applied, so surrounding whitespace is tolerated.
103
+ *
104
+ * @param texts - Candidate text parts (usually the text of a message's parts)
105
+ * @returns The first valid level, or `undefined` when none is present
106
+ *
107
+ * @example
108
+ * findEffortMarker(["run the task", "[codeops-effort: medium]"]) // "medium"
109
+ */
110
+ export function findEffortMarker(texts) {
111
+ if (!Array.isArray(texts)) return undefined
112
+ for (const text of texts) {
113
+ if (typeof text !== "string") continue
114
+ for (const line of text.split(/\r\n|\r|\n/)) {
115
+ const match = MARKER_PATTERN.exec(line.trim())
116
+ if (match) return match[1]
117
+ }
118
+ }
119
+ return undefined
120
+ }
121
+
122
+ /**
123
+ * Resolve the effective level from the three explicit sources.
124
+ *
125
+ * Precedence is "most specific wins": a dispatch marker beats a session
126
+ * flag, which beats a routing default. Each source is validated before it is
127
+ * accepted, so an invalid value is ignored rather than propagated. A
128
+ * non-object argument yields `undefined` instead of throwing.
129
+ *
130
+ * @param input - Candidate sources; all optional
131
+ * @returns The first valid value, or `undefined` when no source applies
132
+ *
133
+ * @example
134
+ * resolveEffort({ session: "high", routing: "low" }) // "high"
135
+ */
136
+ export function resolveEffort(input) {
137
+ if (!isPlainObject(input)) return undefined
138
+ const { marker, session, routing } = input
139
+ if (isEffortLevel(marker)) return marker
140
+ if (isEffortLevel(session)) return session
141
+ if (isRoutingReasoning(routing)) return routing
142
+ return undefined
143
+ }
144
+
145
+ /**
146
+ * Compute the session state-file path inside the session temp directory.
147
+ *
148
+ * @param sessionID - Session identifier
149
+ * @param base - Base temp directory (injectable for tests)
150
+ * @returns Absolute path to `reasoning-effort.json` for the session
151
+ */
152
+ export function sessionEffortPath(sessionID, base) {
153
+ return join(sessionTmpDir(sessionID, base), "reasoning-effort.json")
154
+ }
155
+
156
+ /**
157
+ * Parse session state-file text into a validated level.
158
+ *
159
+ * The expected payload is `{"schema":1,"reasoning":"<level>"}` with any
160
+ * unknown keys ignored. Anything else — malformed JSON, the wrong schema, a
161
+ * level outside the four-level allowlist — yields `undefined`.
162
+ *
163
+ * @param text - Raw file contents
164
+ * @returns The validated level, or `undefined`
165
+ */
166
+ export function parseStateFile(text) {
167
+ try {
168
+ const value = JSON.parse(text)
169
+ if (!isPlainObject(value)) return undefined
170
+ if (value.schema !== 1) return undefined
171
+ return isEffortLevel(value.reasoning) ? value.reasoning : undefined
172
+ } catch {
173
+ return undefined
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Read the session's reasoning-effort state file.
179
+ *
180
+ * The file is written by `scripts/codeops_effort.py` for `--auto-effort`
181
+ * runs. Only a small regular file is accepted: a missing file, a non-regular
182
+ * file (symlinks are never followed), an oversized file, and any read or parse
183
+ * failure all mean "no session effort" and return `undefined`.
184
+ *
185
+ * @param sessionID - Session identifier
186
+ * @param base - Base temp directory (injectable for tests)
187
+ * @returns The session level, or `undefined`
188
+ */
189
+ export function readSessionEffort(sessionID, base) {
190
+ try {
191
+ const path = sessionEffortPath(sessionID, base)
192
+ const info = lstatSync(path)
193
+ if (!info.isFile()) return undefined
194
+ if (info.size > MAX_STATE_FILE_BYTES) return undefined
195
+ return parseStateFile(readFileSync(path, "utf-8"))
196
+ } catch {
197
+ return undefined
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Check whether the optional trace environment switch is on.
203
+ *
204
+ * Tracing is opt-in and diagnostic: it is enabled only when the value is
205
+ * exactly `1` or `true`, compared case-insensitively. Every other value,
206
+ * including `undefined`, disables it.
207
+ *
208
+ * @param value - Raw `CODEOPS_EFFORT_TRACE` value
209
+ * @returns True only for `"1"` or `"true"` in any case
210
+ */
211
+ export function isEffortTraceEnabled(value) {
212
+ if (typeof value !== "string") return false
213
+ const normalized = value.trim().toLowerCase()
214
+ return normalized === "1" || normalized === "true"
215
+ }
216
+
217
+ /**
218
+ * Compute the trace-file path inside the session temp directory.
219
+ *
220
+ * @param sessionID - Session identifier
221
+ * @param base - Base temp directory (injectable for tests)
222
+ * @returns Absolute path to `reasoning-effort-trace.jsonl`
223
+ */
224
+ export function sessionEffortTracePath(sessionID, base) {
225
+ return join(sessionTmpDir(sessionID, base), "reasoning-effort-trace.jsonl")
226
+ }
227
+
228
+ /**
229
+ * Append one content-free trace entry to the session trace file.
230
+ *
231
+ * The entry is serialized as one compact JSON line. Nothing here inspects or
232
+ * interprets the entry; callers must never pass prompt text, file content, or
233
+ * secrets. The session directory is created when missing. Any failure — an
234
+ * unwritable path, an unserializable entry — returns false instead of
235
+ * throwing, because tracing must never affect a request.
236
+ *
237
+ * @param sessionID - Session identifier
238
+ * @param entry - Plain, content-free record to append
239
+ * @param base - Base temp directory (injectable for tests)
240
+ * @returns True when the line was appended, false otherwise
241
+ */
242
+ export function appendEffortTrace(sessionID, entry, base) {
243
+ try {
244
+ const line = `${JSON.stringify(entry)}\n`
245
+ ensureSessionTmpDir(sessionID, base)
246
+ appendFileSync(sessionEffortTracePath(sessionID, base), line, "utf-8")
247
+ return true
248
+ } catch {
249
+ return false
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Read an agent's explicit reasoning entry from the project routing config.
255
+ *
256
+ * Only an own `routing.roles.<agent>.reasoning` property counts; unknown
257
+ * agents, missing sections, inherited properties, and malformed shapes all
258
+ * return `undefined` instead of throwing.
259
+ *
260
+ * @param config - Parsed `codeops/codeops.json` content (any shape)
261
+ * @param agent - Dispatching agent name
262
+ * @returns The configured routing value, or `undefined`
263
+ */
264
+ export function readRoutingReasoning(config, agent) {
265
+ if (!isPlainObject(config)) return undefined
266
+ const routing = config.routing
267
+ if (!isPlainObject(routing)) return undefined
268
+ const roles = routing.roles
269
+ if (!isPlainObject(roles)) return undefined
270
+ if (typeof agent !== "string" || agent.length === 0) return undefined
271
+ if (!Object.prototype.hasOwnProperty.call(roles, agent)) return undefined
272
+ const entry = roles[agent]
273
+ if (!isPlainObject(entry)) return undefined
274
+ return isRoutingReasoning(entry.reasoning) ? entry.reasoning : undefined
275
+ }
276
+
277
+ /**
278
+ * Extract the model's runtime variant record, when the host exposes one.
279
+ *
280
+ * The installed SDK type does not declare `variants`, but the running host
281
+ * attaches it to every model. The `in` check keeps this graceful when the
282
+ * property is absent, and a cast is never used.
283
+ *
284
+ * @param model - Model object from the hook input
285
+ * @returns The variants record when it is a plain object, else `undefined`
286
+ */
287
+ export function extractModelVariants(model) {
288
+ if (!isPlainObject(model)) return undefined
289
+ if (!("variants" in model)) return undefined
290
+ return isPlainObject(model.variants) ? model.variants : undefined
291
+ }
292
+
293
+ /**
294
+ * Check whether a model advertises reasoning support.
295
+ *
296
+ * @param model - Model object from the hook input
297
+ * @returns True only when `capabilities.reasoning` is exactly `true`
298
+ */
299
+ export function modelSupportsReasoning(model) {
300
+ if (!isPlainObject(model)) return false
301
+ const capabilities = model.capabilities
302
+ if (!isPlainObject(capabilities)) return false
303
+ return capabilities.reasoning === true
304
+ }
305
+
306
+ /**
307
+ * Merge the model's variant options for a level into the request options.
308
+ *
309
+ * The runtime model carries a `variants` record whose entries are the exact
310
+ * provider options for each level (for example `reasoningEffort`, or a nested
311
+ * `reasoning.effort`). This function is the only place that mapping is
312
+ * consumed, so the plugin never hardcodes a provider key. The function is
313
+ * pure: it returns a new object when a change applies and the original object
314
+ * reference otherwise.
315
+ *
316
+ * @param options - Current provider options
317
+ * @param level - Candidate level from {@link resolveEffort}
318
+ * @param model - Model object from the hook input
319
+ * @returns The original options, or a new merged object when a change applies
320
+ */
321
+ export function applyEffort(options, level, model) {
322
+ if (!isRoutingReasoning(level)) return options
323
+ if (!modelSupportsReasoning(model)) return options
324
+
325
+ const variants = extractModelVariants(model)
326
+ if (variants !== undefined) {
327
+ if (!Object.prototype.hasOwnProperty.call(variants, level)) return options
328
+ const variantOptions = variants[level]
329
+ if (!isPlainObject(variantOptions)) return options
330
+ return deepMergePlain(options, variantOptions)
331
+ }
332
+
333
+ return { ...options, reasoningEffort: level }
334
+ }
335
+
336
+ /**
337
+ * Recursively merge plain objects into a new object.
338
+ *
339
+ * Values that are not plain objects — arrays, class instances, primitives —
340
+ * are replaced, not merged. Neither input is mutated. The result is created
341
+ * with data properties, so a hostile `__proto__` key in a variant can never
342
+ * change the result's prototype chain.
343
+ *
344
+ * @param target - Base object
345
+ * @param source - Overrides to merge on top
346
+ * @returns A new merged plain object
347
+ */
348
+ export function deepMergePlain(target, source) {
349
+ const result = isPlainObject(target) ? { ...target } : {}
350
+ if (!isPlainObject(source)) return result
351
+
352
+ for (const key of Object.keys(source)) {
353
+ const incoming = source[key]
354
+ const current = Object.prototype.hasOwnProperty.call(result, key)
355
+ ? result[key]
356
+ : undefined
357
+ const merged =
358
+ isPlainObject(incoming) && isPlainObject(current)
359
+ ? deepMergePlain(current, incoming)
360
+ : incoming
361
+ Object.defineProperty(result, key, {
362
+ value: merged,
363
+ writable: true,
364
+ enumerable: true,
365
+ configurable: true,
366
+ })
367
+ }
368
+
369
+ return result
370
+ }
371
+
372
+ /**
373
+ * Check whether a value is a plain object (not an array, class instance, or
374
+ * `null`).
375
+ *
376
+ * @param value - Value to inspect
377
+ * @returns True for `{}`-shaped objects and objects with a null prototype
378
+ */
379
+ function isPlainObject(value) {
380
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false
381
+ const prototype = Object.getPrototypeOf(value)
382
+ return prototype === Object.prototype || prototype === null
383
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
package/plugin/index.ts CHANGED
@@ -9,6 +9,18 @@ import {
9
9
  ensureSessionTmpDir,
10
10
  removeSessionTmpDir,
11
11
  } from "../bin/lib/tmp-hygiene.mjs"
12
+ import {
13
+ appendEffortTrace,
14
+ applyEffort,
15
+ findEffortMarker,
16
+ isEffortLevel,
17
+ isEffortTraceEnabled,
18
+ isRoutingReasoning,
19
+ readRoutingReasoning,
20
+ readSessionEffort,
21
+ resolveEffort,
22
+ } from "../bin/lib/reasoning-effort.mjs"
23
+ import type { EffortLevel } from "../bin/lib/reasoning-effort.mjs"
12
24
 
13
25
  // ---------------------------------------------------------------------------
14
26
  // Package root — resolved at module load time so it is always the plugin's
@@ -137,11 +149,59 @@ async function warnOnVersionSkew(
137
149
  }
138
150
  }
139
151
 
152
+ // ---------------------------------------------------------------------------
153
+ // Helper — log one content-free warning. Logging is best effort: a failed log
154
+ // must never break a request.
155
+ // ---------------------------------------------------------------------------
156
+ async function warnContentFree(
157
+ client: Parameters<Plugin>[0]["client"],
158
+ message: string
159
+ ): Promise<void> {
160
+ try {
161
+ await client.app.log({ body: { service: "codeops", level: "warn", message } })
162
+ } catch {
163
+ // Best effort only.
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Helper — read the project routing config fresh on every request, so an edit
169
+ // applies without restarting the session. Any failure means "no routing".
170
+ // ---------------------------------------------------------------------------
171
+ function readRoutingConfig(directory: string): unknown {
172
+ try {
173
+ return JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
174
+ } catch {
175
+ return {}
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Helper — append one content-free trace line when the optional
181
+ // CODEOPS_EFFORT_TRACE switch is on. Tracing is diagnostic only: it never
182
+ // affects a request and swallows its own failures.
183
+ // ---------------------------------------------------------------------------
184
+ function traceEffort(
185
+ enabled: boolean,
186
+ sessionID: string,
187
+ entry: Record<string, unknown>
188
+ ): void {
189
+ if (!enabled) return
190
+ appendEffortTrace(sessionID, { ts: new Date().toISOString(), ...entry })
191
+ }
192
+
140
193
  // ---------------------------------------------------------------------------
141
194
  // CodeOps plugin for OpenCode
142
195
  // Replaces: hooks/hooks.json + hook_session_context.sh + hook_marker_guard.sh
143
196
  // ---------------------------------------------------------------------------
144
197
  export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
198
+ // Reasoning-effort state lives for the lifetime of this plugin instance:
199
+ // one entry per user message that carried a dispatch marker, plus a
200
+ // deduplication set for unsupported-level warnings.
201
+ const effortMarkers = new Map<string, { sessionID: string; level: EffortLevel }>()
202
+ const warnedEffortLevels = new Set<string>()
203
+ const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
204
+
145
205
  return {
146
206
  // -----------------------------------------------------------------------
147
207
  // Hook 1 & 2: inject standards on session.created and session.compacted.
@@ -160,6 +220,13 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
160
220
  } else if (event.type === "session.deleted") {
161
221
  const info = (event.properties as { info: { id: string } }).info
162
222
  removeSessionTmpDir(info.id)
223
+ try {
224
+ for (const [messageID, entry] of effortMarkers) {
225
+ if (entry.sessionID === info.id) effortMarkers.delete(messageID)
226
+ }
227
+ } catch {
228
+ await warnContentFree(client, "Could not clear captured reasoning-effort markers.")
229
+ }
163
230
  } else if (event.type === "session.compacted") {
164
231
  const sessionId: string = (event.properties as { sessionID: string }).sessionID
165
232
  await injectStandards(client, sessionId)
@@ -218,5 +285,90 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
218
285
  )
219
286
  }
220
287
  },
288
+
289
+ // -----------------------------------------------------------------------
290
+ // Hook 6: capture a dispatch marker from an incoming user message. The
291
+ // marker travels in the dispatch packet text; storing it by message id
292
+ // lets the later chat.params hook apply it to the same request.
293
+ // -----------------------------------------------------------------------
294
+ "chat.message": async (input, output) => {
295
+ try {
296
+ const texts = output.parts.map((part) =>
297
+ part?.type === "text" ? part.text : undefined
298
+ )
299
+ const level = findEffortMarker(texts)
300
+ if (level !== undefined) {
301
+ effortMarkers.set(output.message.id, { sessionID: input.sessionID, level })
302
+ traceEffort(effortTraceEnabled, input.sessionID, {
303
+ event: "capture",
304
+ messageID: output.message.id,
305
+ level,
306
+ })
307
+ }
308
+ } catch {
309
+ await warnContentFree(client, "Could not scan a message for a reasoning-effort marker.")
310
+ }
311
+ },
312
+
313
+ // -----------------------------------------------------------------------
314
+ // Hook 7: resolve the request's reasoning level (dispatch marker, then
315
+ // session flag, then routing default) and merge the model's own variant
316
+ // options. Any failure leaves the request unchanged.
317
+ // -----------------------------------------------------------------------
318
+ "chat.params": async (input, output) => {
319
+ try {
320
+ const stored = effortMarkers.get(input.message.id)
321
+ const marker = stored && stored.sessionID === input.sessionID ? stored.level : undefined
322
+ const session = readSessionEffort(input.sessionID)
323
+ const routing = readRoutingReasoning(readRoutingConfig(directory), input.agent)
324
+ const level = resolveEffort({ marker, session, routing })
325
+ const source = isEffortLevel(marker)
326
+ ? "marker"
327
+ : isEffortLevel(session)
328
+ ? "session"
329
+ : isRoutingReasoning(routing)
330
+ ? "routing"
331
+ : "none"
332
+ if (level === undefined) {
333
+ traceEffort(effortTraceEnabled, input.sessionID, {
334
+ event: "apply",
335
+ messageID: input.message.id,
336
+ agent: input.agent,
337
+ level: null,
338
+ source,
339
+ applied: false,
340
+ })
341
+ return
342
+ }
343
+
344
+ const applied = applyEffort(output.options, level, input.model)
345
+ const changed = applied !== output.options
346
+ traceEffort(effortTraceEnabled, input.sessionID, {
347
+ event: "apply",
348
+ messageID: input.message.id,
349
+ agent: input.agent,
350
+ level,
351
+ source,
352
+ applied: changed,
353
+ })
354
+ if (!changed) {
355
+ if (marker !== undefined) {
356
+ const warningKey = `${input.sessionID}:${marker}`
357
+ if (!warnedEffortLevels.has(warningKey)) {
358
+ warnedEffortLevels.add(warningKey)
359
+ await warnContentFree(
360
+ client,
361
+ `Reasoning effort ${marker} is not available for agent ${input.agent}; ` +
362
+ "request left unchanged."
363
+ )
364
+ }
365
+ }
366
+ return
367
+ }
368
+ output.options = applied
369
+ } catch {
370
+ await warnContentFree(client, "Could not apply a reasoning-effort level to a request.")
371
+ }
372
+ },
221
373
  }
222
374
  }