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.
- package/CHANGELOG.md +31 -0
- package/README.md +16 -0
- package/_shared/quality-profile.md +4 -0
- package/_shared/reasoning-effort.md +144 -0
- package/bin/lib/reasoning-effort.d.mts +98 -0
- package/bin/lib/reasoning-effort.mjs +383 -0
- package/package.json +1 -1
- package/plugin/index.ts +152 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/codeops_effort.py +216 -0
- package/skills/exec-plan/SKILL.md +12 -0
- package/skills/exec-plan/execution-protocol.md +43 -3
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/make-plan/SKILL.md +17 -0
- package/skills/make-plan/templates.md +2 -0
- package/skills/make-requirements/SKILL.md +10 -0
- package/skills/preflight/SKILL.md +10 -0
- package/skills/retro-requirements/SKILL.md +10 -0
- package/skills/setup-routing/SKILL.md +2 -0
- package/skills/setup-routing/routing.md +16 -1
- package/skills/upgrade-plan/SKILL.md +10 -0
|
@@ -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
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
|
}
|
|
Binary file
|