opencode-harness-kit 0.0.0-stage → 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/GLOBAL.md +110 -0
- package/LICENSE +21 -0
- package/README.md +421 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +977 -0
- package/dist/index.js +976 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +77 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +60 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/schema.json +132 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +326 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +333 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +496 -0
- package/src/telemetry.ts +98 -0
package/src/profile.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Profile loading, effort resolution, and retention lookup.
|
|
2
|
+
//
|
|
3
|
+
// The rule this file exists to enforce: no plugin in src/ names a model. Every
|
|
4
|
+
// model-specific decision comes from a profile JSON, so switching models means
|
|
5
|
+
// editing data, not code.
|
|
6
|
+
//
|
|
7
|
+
// Plain JS with JSDoc types on purpose: the opencode plugin loads .ts, but the
|
|
8
|
+
// CLI has to run under plain node, so the shared core stays dependency-free.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs"
|
|
11
|
+
import { dirname, join, resolve } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
export const ROOT = resolve(HERE, "..")
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {"zero-retention" | "may-train" | "unknown"} Retention
|
|
19
|
+
* @typedef {string | null} EffortRequest
|
|
20
|
+
* @typedef {object} Profile
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {string} [title]
|
|
23
|
+
* @property {string} provider
|
|
24
|
+
* @property {string} model
|
|
25
|
+
* @property {string | null} [family]
|
|
26
|
+
* @property {string} [verified]
|
|
27
|
+
* @property {Retention} retention
|
|
28
|
+
* @property {string} [notes]
|
|
29
|
+
* @property {{ context: number, input?: number, output: number }} limits
|
|
30
|
+
* @property {{ reasoning?: boolean, temperature?: boolean, toolCall?: boolean,
|
|
31
|
+
* structuredOutput?: boolean, attachment?: boolean }} [supports]
|
|
32
|
+
* @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
|
|
33
|
+
* @property {Record<string, EffortRequest>} [roles]
|
|
34
|
+
* @property {{ enabled?: boolean, keepRecentToolCalls?: number,
|
|
35
|
+
* keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
|
|
36
|
+
* @property {string[]} [fallback]
|
|
37
|
+
* @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export class ProfileError extends Error {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Profiles live in profiles/ (verified) and examples/ (contrarian, still
|
|
44
|
+
* installable). schema.json and index.json are not profiles.
|
|
45
|
+
*/
|
|
46
|
+
export function profileDirs() {
|
|
47
|
+
return [join(ROOT, "profiles"), join(ROOT, "examples")]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} id @returns {string | null} */
|
|
51
|
+
export function profilePath(id) {
|
|
52
|
+
for (const dir of profileDirs()) {
|
|
53
|
+
const full = join(dir, `${id}.json`)
|
|
54
|
+
if (existsSync(full)) return full
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns {string[]} */
|
|
60
|
+
export function listProfileIds() {
|
|
61
|
+
const out = []
|
|
62
|
+
for (const dir of profileDirs()) {
|
|
63
|
+
if (!existsSync(dir)) continue
|
|
64
|
+
for (const f of readdirSync(dir)) {
|
|
65
|
+
if (!f.endsWith(".json")) continue
|
|
66
|
+
if (f === "schema.json" || f === "index.json") continue
|
|
67
|
+
out.push(f.replace(/\.json$/, ""))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out.sort()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {string} id @returns {Profile} */
|
|
74
|
+
export function loadProfile(id) {
|
|
75
|
+
const path = profilePath(id)
|
|
76
|
+
if (!path) {
|
|
77
|
+
throw new ProfileError(`no profile named "${id}". Run: oc-harness list`)
|
|
78
|
+
}
|
|
79
|
+
let raw
|
|
80
|
+
try {
|
|
81
|
+
raw = JSON.parse(readFileSync(path, "utf8"))
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
84
|
+
throw new ProfileError(`cannot read ${path}: ${msg}`)
|
|
85
|
+
}
|
|
86
|
+
validate(raw, path)
|
|
87
|
+
return normalize(raw)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fill in derived fields so nothing downstream has to guess.
|
|
92
|
+
* @param {Profile} p @returns {Profile}
|
|
93
|
+
*/
|
|
94
|
+
export function normalize(p) {
|
|
95
|
+
return {
|
|
96
|
+
...p,
|
|
97
|
+
limits: { ...p.limits, input: p.limits.input ?? p.limits.context },
|
|
98
|
+
effort: {
|
|
99
|
+
levels: p.effort?.levels ?? [],
|
|
100
|
+
default: p.effort?.default ?? null,
|
|
101
|
+
reserveShare: p.effort?.reserveShare ?? 0.12,
|
|
102
|
+
},
|
|
103
|
+
prune: {
|
|
104
|
+
enabled: p.prune?.enabled ?? false,
|
|
105
|
+
keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
|
|
106
|
+
keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
|
|
107
|
+
keepUserMessages: p.prune?.keepUserMessages ?? true,
|
|
108
|
+
},
|
|
109
|
+
fallback: p.fallback ?? [],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const VALID_RETENTION = ["zero-retention", "may-train", "unknown"]
|
|
114
|
+
|
|
115
|
+
/** @param {Profile} p @param {string} [path] */
|
|
116
|
+
export function validate(p, path = "<memory>") {
|
|
117
|
+
const where = p?.id ?? path
|
|
118
|
+
if (!p?.id) throw new ProfileError(`${path}: missing "id"`)
|
|
119
|
+
if (!p.provider) throw new ProfileError(`${where}: missing "provider"`)
|
|
120
|
+
if (!p.model) throw new ProfileError(`${where}: missing "model"`)
|
|
121
|
+
if (!VALID_RETENTION.includes(p.retention)) {
|
|
122
|
+
throw new ProfileError(
|
|
123
|
+
`${where}: retention must be one of ${VALID_RETENTION.join(", ")}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!p.limits?.context || p.limits.context <= 0) {
|
|
127
|
+
throw new ProfileError(`${where}: limits.context must be a positive integer`)
|
|
128
|
+
}
|
|
129
|
+
if (!p.limits?.output || p.limits.output <= 0) {
|
|
130
|
+
throw new ProfileError(`${where}: limits.output must be a positive integer`)
|
|
131
|
+
}
|
|
132
|
+
const share = p.effort?.reserveShare
|
|
133
|
+
if (share !== undefined && (share < 0 || share > 0.5)) {
|
|
134
|
+
throw new ProfileError(
|
|
135
|
+
`${where}: effort.reserveShare must be between 0 and 0.5`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Canonical ordering for reasoning effort, used to snap a requested level onto
|
|
141
|
+
// whatever the target model actually offers.
|
|
142
|
+
const CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pick the available level closest to what a role asked for.
|
|
146
|
+
*
|
|
147
|
+
* A role asking for `max` on a model topping out at `xhigh` gets `xhigh`. A
|
|
148
|
+
* model with no tunable effort resolves to null, meaning "emit no variant"
|
|
149
|
+
* rather than "emit a level the model does not have".
|
|
150
|
+
*
|
|
151
|
+
* @param {string[]} levels
|
|
152
|
+
* @param {EffortRequest} requested
|
|
153
|
+
* @returns {string | null}
|
|
154
|
+
*/
|
|
155
|
+
export function resolveEffort(levels, requested) {
|
|
156
|
+
/** @type {string | null} */
|
|
157
|
+
if (!requested) return null
|
|
158
|
+
if (!levels || levels.length === 0) return null
|
|
159
|
+
if (levels.includes(requested)) return requested
|
|
160
|
+
|
|
161
|
+
const want = CANON.indexOf(requested)
|
|
162
|
+
if (want < 0) {
|
|
163
|
+
// Unknown label. Snap to the top level, which is the safe reading of a
|
|
164
|
+
// role that asked for something strong.
|
|
165
|
+
return levels[levels.length - 1] ?? null
|
|
166
|
+
}
|
|
167
|
+
let best = null
|
|
168
|
+
let bestDist = Infinity
|
|
169
|
+
for (const level of levels) {
|
|
170
|
+
const have = CANON.indexOf(level)
|
|
171
|
+
if (have < 0) continue
|
|
172
|
+
const d = Math.abs(have - want)
|
|
173
|
+
if (d < bestDist) {
|
|
174
|
+
bestDist = d
|
|
175
|
+
best = level
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return best ?? levels[levels.length - 1] ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {Profile} p
|
|
183
|
+
* @returns {Record<string, string | null>}
|
|
184
|
+
*/
|
|
185
|
+
export function resolveRoles(p) {
|
|
186
|
+
const levels = p.effort?.levels ?? []
|
|
187
|
+
const out = /** @type {Record<string, string | null>} */ ({})
|
|
188
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
189
|
+
out[role] = resolveEffort(levels, want)
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {Profile} p @returns {string | null} */
|
|
195
|
+
export function resolveDefaultVariant(p) {
|
|
196
|
+
return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Tokens held back from the input ceiling for reasoning growth and in-flight
|
|
201
|
+
* tool output. Scaled from the model, never hardcoded.
|
|
202
|
+
* @param {Profile} p @returns {number}
|
|
203
|
+
*/
|
|
204
|
+
export function reservedTokens(p) {
|
|
205
|
+
const input = p.limits.input ?? p.limits.context
|
|
206
|
+
const share = p.effort?.reserveShare ?? 0.12
|
|
207
|
+
return Math.max(8000, Math.round(input * share))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Context budget above which pruning should already be working.
|
|
212
|
+
* @param {Profile} p @returns {number}
|
|
213
|
+
*/
|
|
214
|
+
export function pruneThreshold(p) {
|
|
215
|
+
return (p.limits.input ?? p.limits.context) - reservedTokens(p)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Minimal YAML reader for retention.yml: a two-level map of small records,
|
|
220
|
+
* including folded block scalars (`note: >-`). Handling indentation, folded
|
|
221
|
+
* blocks and plain scalars costs about forty lines, which is less than a
|
|
222
|
+
* dependency for a file this shape.
|
|
223
|
+
*
|
|
224
|
+
* @returns {Record<string, RetentionRecord>}
|
|
225
|
+
*/
|
|
226
|
+
export function loadRetention() {
|
|
227
|
+
const file = join(ROOT, "retention.yml")
|
|
228
|
+
if (!existsSync(file)) return {}
|
|
229
|
+
const out = /** @type {Record<string, any>} */ ({})
|
|
230
|
+
const lines = readFileSync(file, "utf8").split("\n")
|
|
231
|
+
|
|
232
|
+
let current = null
|
|
233
|
+
let i = 0
|
|
234
|
+
|
|
235
|
+
while (i < lines.length) {
|
|
236
|
+
const raw = lines[i]
|
|
237
|
+
const line = raw.trim()
|
|
238
|
+
const indent = raw.length - raw.trimStart().length
|
|
239
|
+
|
|
240
|
+
if (!line || line.startsWith("#")) {
|
|
241
|
+
i++
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
// A model key sits at two-space indent under `models:`. Keys are written
|
|
245
|
+
// provider-qualified already, so do not re-prefix them.
|
|
246
|
+
if (indent === 2 && line.endsWith(":")) {
|
|
247
|
+
const key = line.slice(0, -1).trim()
|
|
248
|
+
current = key.includes("/") ? key : `opencode/${key}`
|
|
249
|
+
out[current] = { retention: "unknown" }
|
|
250
|
+
i++
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (!current) {
|
|
254
|
+
i++
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const field = line.match(/^(retention|verified|source|note):\s*(.*)$/)
|
|
259
|
+
if (field) {
|
|
260
|
+
const name = field[1]
|
|
261
|
+
const rest = field[2].trim()
|
|
262
|
+
if (/^[>|][-+]?$/.test(rest)) {
|
|
263
|
+
// Folded or literal block: gather every following line indented deeper
|
|
264
|
+
// than its key, then collapse whitespace into a one-line note.
|
|
265
|
+
const parts = []
|
|
266
|
+
let j = i + 1
|
|
267
|
+
for (; j < lines.length; j++) {
|
|
268
|
+
const l = lines[j]
|
|
269
|
+
if (!l.trim()) {
|
|
270
|
+
parts.push("")
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (l.length - l.trimStart().length <= indent) break
|
|
274
|
+
parts.push(l.trim())
|
|
275
|
+
}
|
|
276
|
+
out[current][name] = parts.join(" ").replace(/\s+/g, " ").trim()
|
|
277
|
+
i = j
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
out[current][name] = rest.replace(/^["']|["']$/g, "")
|
|
281
|
+
i++
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
i++
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Retention as declared in retention.yml, which wins over the profile copy.
|
|
291
|
+
* Accepts either a bare profile id or an already-qualified provider/model.
|
|
292
|
+
* @param {string} id @returns {RetentionRecord | undefined}
|
|
293
|
+
*/
|
|
294
|
+
export function retentionFor(id) {
|
|
295
|
+
const table = loadRetention()
|
|
296
|
+
return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Where opencode keeps its config, honouring the same env var it does. */
|
|
300
|
+
export function configFilePath() {
|
|
301
|
+
const dir =
|
|
302
|
+
process.env.OPENCODE_CONFIG_DIR ??
|
|
303
|
+
join(process.env.HOME ?? "", ".config", "opencode")
|
|
304
|
+
return join(dir, "opencode.jsonc")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read the active profile id from the config file on disk.
|
|
309
|
+
*
|
|
310
|
+
* This exists because the `config` hook cannot be the source. OpenCode's
|
|
311
|
+
* Config schema sets `additionalProperties: false`, so a custom `harness` key
|
|
312
|
+
* written into the file is stripped from the object handed to the hook. The
|
|
313
|
+
* file survives; the in-memory config does not. Verified against the 1.18.35
|
|
314
|
+
* schema.
|
|
315
|
+
*
|
|
316
|
+
* @returns {string | null}
|
|
317
|
+
*/
|
|
318
|
+
export function profileIdFromConfig() {
|
|
319
|
+
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE
|
|
320
|
+
if (fromEnv) return fromEnv.trim() || null
|
|
321
|
+
|
|
322
|
+
const file = configFilePath()
|
|
323
|
+
if (!existsSync(file)) return null
|
|
324
|
+
try {
|
|
325
|
+
const text = readFileSync(file, "utf8")
|
|
326
|
+
const json = text.slice(text.indexOf("{"))
|
|
327
|
+
const parsed = JSON.parse(json)
|
|
328
|
+
const id = parsed?.harness?.profile
|
|
329
|
+
return typeof id === "string" && id ? id : null
|
|
330
|
+
} catch {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Deterministic context pruning.
|
|
2
|
+
//
|
|
3
|
+
// Measured on one install: 77.9% of stored message bytes were tool output and
|
|
4
|
+
// 15.6% reasoning, against 2.1% of actual text. Cache reads ran 20.6x fresh
|
|
5
|
+
// input, and p50 context per request was ~99k tokens with only ~534 fresh
|
|
6
|
+
// tokens. The session was re-sending old command output on every turn.
|
|
7
|
+
//
|
|
8
|
+
// Why not let the model decide when to compact: the model-driven approach needs
|
|
9
|
+
// the model to remember to call a tool, and this kit targets models with weak
|
|
10
|
+
// instruction following. This hook runs unconditionally and keeps pointers
|
|
11
|
+
// instead of bodies, so nothing is lost, only re-read on demand.
|
|
12
|
+
//
|
|
13
|
+
// Invariants, because this rewrites history on every turn:
|
|
14
|
+
// - never touch the last message
|
|
15
|
+
// - never touch a tool call from the turn in flight
|
|
16
|
+
// - never touch patch or file parts, which carry diffs the model needs
|
|
17
|
+
// - never stub a user message unless asked to
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
20
|
+
import { loadProfile, pruneThreshold, ROOT } from "./profile.js"
|
|
21
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
22
|
+
import { join } from "node:path"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which profile is active. In order: an explicit environment variable, the
|
|
26
|
+
* marker the plugin writes into the project, then a marker inside the package.
|
|
27
|
+
* Returns null when nothing is set, and pruning then stays off rather than
|
|
28
|
+
* guessing at thresholds.
|
|
29
|
+
*/
|
|
30
|
+
function activeProfileId(worktree: string): string | null {
|
|
31
|
+
const candidates = [
|
|
32
|
+
process.env.OPENCODE_HARNESS_PROFILE,
|
|
33
|
+
join(worktree, ".space", "active"),
|
|
34
|
+
join(ROOT, "profiles", ".active"),
|
|
35
|
+
]
|
|
36
|
+
for (const f of candidates) {
|
|
37
|
+
if (!f) continue
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(f)) {
|
|
40
|
+
const v = readFileSync(f, "utf8").trim()
|
|
41
|
+
if (v) return v
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Try the next location.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StubStats {
|
|
51
|
+
stubbed: number
|
|
52
|
+
kept: number
|
|
53
|
+
bytesBefore: number
|
|
54
|
+
bytesAfter: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Rough size of a part, used only for the stats line. */
|
|
58
|
+
function sizeOf(part: any): number {
|
|
59
|
+
if (typeof part?.text === "string") return part.text.length
|
|
60
|
+
if (typeof part?.state?.output === "string") return part.state.output.length
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One line standing in for a tool result. The agent keeps the shape of what
|
|
66
|
+
* happened and a way to get the rest, which is what it actually needs to decide
|
|
67
|
+
* what to do next.
|
|
68
|
+
*/
|
|
69
|
+
function stub(part: any): string {
|
|
70
|
+
const tool = part?.tool ?? "tool"
|
|
71
|
+
const state = part?.state ?? {}
|
|
72
|
+
const input = state.input ?? {}
|
|
73
|
+
|
|
74
|
+
if (tool === "bash") {
|
|
75
|
+
const cmd = String(input.command ?? "").replace(/\s+/g, " ").slice(0, 80)
|
|
76
|
+
const status = state.status ?? "done"
|
|
77
|
+
let tail = ""
|
|
78
|
+
const out = String(state.output ?? "")
|
|
79
|
+
const m = out.match(/(passed|failed|error|Error|ok|OK|\d+ (?:passed|failed))/g)
|
|
80
|
+
if (m && m.length) tail = `, ${m[m.length - 1]}`
|
|
81
|
+
return `[${tool}] ${cmd} -> ${status}${tail} | full output was pruned from context; re-run the command if you need it`
|
|
82
|
+
}
|
|
83
|
+
if (tool === "read" || tool === "grep" || tool === "glob" || tool === "list") {
|
|
84
|
+
const target = String(input.filePath ?? input.path ?? input.pattern ?? "")
|
|
85
|
+
return `[${tool}] ${target.slice(0, 100)} | result pruned from context; re-read it if you need it`
|
|
86
|
+
}
|
|
87
|
+
return `[${tool}] result pruned from context (${state.status ?? "done"}); re-run if you need it`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const PrunePlugin: Plugin = async ({ worktree, $ }) => {
|
|
91
|
+
let profile = null as ReturnType<typeof loadProfile> | null
|
|
92
|
+
try {
|
|
93
|
+
const id = activeProfileId(worktree)
|
|
94
|
+
if (id) profile = loadProfile(id)
|
|
95
|
+
} catch {
|
|
96
|
+
profile = null
|
|
97
|
+
}
|
|
98
|
+
if (!profile?.prune?.enabled) {
|
|
99
|
+
// Pruning is opt-in per profile. With no active profile we do nothing
|
|
100
|
+
// rather than guess at thresholds.
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const keepCalls = profile.prune.keepRecentToolCalls ?? 6
|
|
105
|
+
const keepTurns = profile.prune.keepRecentTurns ?? 3
|
|
106
|
+
const keepUsers = profile.prune.keepUserMessages ?? true
|
|
107
|
+
const threshold = pruneThreshold(profile)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
111
|
+
try {
|
|
112
|
+
const messages = output.messages
|
|
113
|
+
if (!Array.isArray(messages) || messages.length === 0) return
|
|
114
|
+
|
|
115
|
+
// The trailing keepTurns are off limits, and the last message always is.
|
|
116
|
+
const floor = Math.max(0, messages.length - 1 - keepTurns)
|
|
117
|
+
|
|
118
|
+
// Walk backwards collecting tool parts to preserve verbatim.
|
|
119
|
+
// Walk backwards from the end until keepCalls tool parts are collected.
|
|
120
|
+
// This is deliberately not bounded by `floor`: how many recent calls
|
|
121
|
+
// stay verbatim and which turns are prunable are independent limits,
|
|
122
|
+
// and a call can be both recent and inside the prunable span.
|
|
123
|
+
const keepToolMessageIds = new Set<string>()
|
|
124
|
+
let seen = 0
|
|
125
|
+
for (let i = messages.length - 1; i >= 0 && seen < keepCalls; i--) {
|
|
126
|
+
const msg = messages[i]
|
|
127
|
+
if (!msg?.parts) continue
|
|
128
|
+
for (const part of msg.parts) {
|
|
129
|
+
if (part?.type !== "tool") continue
|
|
130
|
+
if (seen >= keepCalls) break
|
|
131
|
+
keepToolMessageIds.add(msg.info?.id ?? String(i))
|
|
132
|
+
seen++
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stats: StubStats = {
|
|
137
|
+
stubbed: 0,
|
|
138
|
+
kept: 0,
|
|
139
|
+
bytesBefore: 0,
|
|
140
|
+
bytesAfter: 0,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < floor; i++) {
|
|
144
|
+
const msg = messages[i]
|
|
145
|
+
if (!msg?.parts) continue
|
|
146
|
+
const isUser = msg.info?.role === "user"
|
|
147
|
+
for (const part of msg.parts) {
|
|
148
|
+
const size = sizeOf(part)
|
|
149
|
+
stats.bytesBefore += size
|
|
150
|
+
|
|
151
|
+
if (isUser && keepUsers) {
|
|
152
|
+
stats.bytesAfter += size
|
|
153
|
+
stats.kept++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (keepToolMessageIds.has(msg.info?.id ?? String(i))) {
|
|
157
|
+
stats.bytesAfter += size
|
|
158
|
+
stats.kept++
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
switch (part?.type) {
|
|
162
|
+
case "patch":
|
|
163
|
+
case "file":
|
|
164
|
+
// Diffs and file bodies are load-bearing; never stub them.
|
|
165
|
+
stats.bytesAfter += size
|
|
166
|
+
stats.kept++
|
|
167
|
+
break
|
|
168
|
+
case "reasoning":
|
|
169
|
+
// Old reasoning is stale by definition and safe to drop.
|
|
170
|
+
stats.bytesAfter += Math.min(size, 200)
|
|
171
|
+
stats.stubbed++
|
|
172
|
+
break
|
|
173
|
+
case "tool": {
|
|
174
|
+
// Only completed calls carry an output worth pruning. Pending
|
|
175
|
+
// and running states are small, and error states are usually
|
|
176
|
+
// the most useful thing in the transcript.
|
|
177
|
+
const state = part.state
|
|
178
|
+
if (state?.status !== "completed") {
|
|
179
|
+
stats.bytesAfter += size
|
|
180
|
+
stats.kept++
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
part.state = {
|
|
184
|
+
status: "completed",
|
|
185
|
+
input: state.input,
|
|
186
|
+
output: stub(part),
|
|
187
|
+
title: state.title,
|
|
188
|
+
time: state.time,
|
|
189
|
+
metadata: { ...(state.metadata ?? {}), pruned: true, prunedAt: new Date().toISOString() },
|
|
190
|
+
}
|
|
191
|
+
stats.bytesAfter += sizeOf(part)
|
|
192
|
+
stats.stubbed++
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
case "text":
|
|
196
|
+
// Assistant prose carries the reasoning chain a human reads and
|
|
197
|
+
// the model later relies on. Keep it.
|
|
198
|
+
stats.bytesAfter += size
|
|
199
|
+
stats.kept++
|
|
200
|
+
break
|
|
201
|
+
default:
|
|
202
|
+
stats.bytesAfter += size
|
|
203
|
+
stats.kept++
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (stats.stubbed > 0) {
|
|
209
|
+
const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter)
|
|
210
|
+
await logPrune(profile.id, {
|
|
211
|
+
...stats,
|
|
212
|
+
saved,
|
|
213
|
+
threshold,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// A failed prune must never break the turn. Skipping is recoverable;
|
|
218
|
+
// corrupting history is not.
|
|
219
|
+
console.error(
|
|
220
|
+
`[opencode-harness-kit] prune skipped: ${
|
|
221
|
+
err instanceof Error ? err.message : String(err)
|
|
222
|
+
}`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function logPrune(
|
|
230
|
+
profileId: string,
|
|
231
|
+
stats: StubStats & {
|
|
232
|
+
saved: number
|
|
233
|
+
threshold: number
|
|
234
|
+
},
|
|
235
|
+
) {
|
|
236
|
+
const dir = join(process.cwd(), ".space")
|
|
237
|
+
try {
|
|
238
|
+
const { mkdir, appendFile } = await import("node:fs/promises")
|
|
239
|
+
await mkdir(dir, { recursive: true })
|
|
240
|
+
await appendFile(
|
|
241
|
+
join(dir, "prune.jsonl"),
|
|
242
|
+
JSON.stringify({
|
|
243
|
+
t: new Date().toISOString(),
|
|
244
|
+
profile: profileId,
|
|
245
|
+
stubbed: stats.stubbed,
|
|
246
|
+
kept: stats.kept,
|
|
247
|
+
savedBytes: stats.saved,
|
|
248
|
+
threshold: stats.threshold,
|
|
249
|
+
}) + "\n",
|
|
250
|
+
)
|
|
251
|
+
} catch {
|
|
252
|
+
// Telemetry must never be load-bearing.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const pruneMeta = { note: "opt-in via profile prune.enabled" }
|
|
257
|
+
|
|
258
|
+
export default PrunePlugin
|