openhermes 1.5.6 → 1.13.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/LICENSE +21 -0
- package/README.md +217 -111
- package/autorecall.mjs +2 -12
- package/bootstrap.mjs +160 -8
- package/curator.mjs +1 -5
- package/harness/commands/checkpoint.md +68 -0
- package/harness/commands/eval.md +89 -0
- package/harness/commands/go-build.md +87 -0
- package/harness/commands/go-review.md +71 -0
- package/harness/commands/harness-audit.md +90 -0
- package/harness/commands/learn.md +2 -2
- package/harness/commands/loop-start.md +38 -0
- package/harness/commands/loop-status.md +30 -0
- package/harness/commands/memory-search.md +2 -2
- package/harness/commands/model-route.md +32 -0
- package/harness/commands/ohc.md +13 -0
- package/harness/commands/orchestrate.md +88 -0
- package/harness/commands/quality-gate.md +35 -0
- package/harness/commands/refactor-clean.md +102 -0
- package/harness/commands/rust-build.md +78 -0
- package/harness/commands/rust-review.md +65 -0
- package/harness/commands/setup-pm.md +65 -0
- package/harness/commands/skill-create.md +99 -0
- package/harness/commands/test-coverage.md +80 -0
- package/harness/commands/update-codemaps.md +81 -0
- package/harness/commands/update-docs.md +67 -0
- package/harness/commands/verify.md +68 -0
- package/harness/instructions/CONVENTIONS.md +206 -0
- package/harness/instructions/RUNTIME.md +8 -1
- package/harness/prompts/build-cpp.md +84 -0
- package/harness/prompts/build-error-resolver.md +2 -1
- package/harness/prompts/build-go.md +326 -0
- package/harness/prompts/build-java.md +126 -0
- package/harness/prompts/build-kotlin.md +123 -0
- package/harness/prompts/build-rust.md +94 -0
- package/harness/prompts/code-reviewer.md +2 -1
- package/harness/prompts/doc-updater.md +193 -0
- package/harness/prompts/docs-lookup.md +60 -0
- package/harness/prompts/explore.md +1 -0
- package/harness/prompts/harness-optimizer.md +30 -0
- package/harness/prompts/loop-operator.md +42 -0
- package/harness/prompts/planner.md +3 -2
- package/harness/prompts/refactor-cleaner.md +242 -0
- package/harness/prompts/review-cpp.md +68 -0
- package/harness/prompts/review-database.md +248 -0
- package/harness/prompts/review-go.md +244 -0
- package/harness/prompts/review-java.md +100 -0
- package/harness/prompts/review-kotlin.md +130 -0
- package/harness/prompts/review-python.md +88 -0
- package/harness/prompts/review-rust.md +64 -0
- package/harness/prompts/security-reviewer.md +3 -2
- package/harness/prompts/tdd-guide.md +214 -0
- package/harness/rules/delegation.md +28 -22
- package/harness/rules/memory-management.md +4 -4
- package/harness/rules/retrieval.md +5 -5
- package/harness/rules/runtime-guards.md +1 -1
- package/harness/rules/session-start.md +4 -4
- package/harness/rules/skills-management.md +2 -2
- package/harness/rules/state-drift.md +1 -1
- package/harness/rules/verification.md +4 -4
- package/harness/scripts/sync-commands.mjs +259 -0
- package/harness/skills/coding-standards/SKILL.md +1 -1
- package/index.mjs +25 -4
- package/lib/hardening.mjs +11 -1
- package/lib/memory-tools-plugin.mjs +84 -71
- package/lib/ohc/config.mjs +30 -0
- package/lib/ohc/pruner.mjs +239 -0
- package/lib/ohc/reaper.mjs +61 -0
- package/lib/ohc/state.mjs +32 -0
- package/lib/ohc/updater.mjs +110 -0
- package/package.json +6 -2
- package/skill-builder.mjs +2 -6
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
|
-
import { fileURLToPath } from "url"
|
|
3
|
-
import path from "path"
|
|
4
|
-
import fs from "fs"
|
|
5
|
-
import os from "os"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import os from "node:os"
|
|
6
6
|
|
|
7
|
-
import { atomicWriteJson, fingerprintEnvironment, sanitizeRecord, truncateText } from "./hardening.mjs"
|
|
7
|
+
import { atomicWriteJson, fingerprintEnvironment, readJson, readJsonl, sanitizeRecord, truncateText } from "./hardening.mjs"
|
|
8
8
|
import { findUnsupportedSchemaKeywords, validateSchema } from "./schema-validator.mjs"
|
|
9
|
-
import { getMemoryRoot, getRuntimeRoot } from "
|
|
9
|
+
import { getMemoryRoot, getRuntimeRoot } from "./paths.mjs"
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
12
12
|
const SCHEMAS_DIR = path.resolve(__dirname, "..", "schemas")
|
|
@@ -44,16 +44,6 @@ function hasExpired(r) {
|
|
|
44
44
|
return false
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function readJSON(fp, fallback) {
|
|
48
|
-
try { return JSON.parse(fs.readFileSync(fp, "utf8")) } catch { return fallback }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function readJSONL(fp) {
|
|
52
|
-
try {
|
|
53
|
-
return fs.readFileSync(fp, "utf8").split(/\r?\n/).map(l => l.trim()).filter(Boolean).map(l => JSON.parse(l))
|
|
54
|
-
} catch { return [] }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
47
|
function sortRecent(entries) {
|
|
58
48
|
function ts(e) { return e?.updated_at ?? e?.created_at ?? "" }
|
|
59
49
|
return [...entries].sort((a, b) => {
|
|
@@ -71,7 +61,7 @@ function writeObject(cls, record) {
|
|
|
71
61
|
const fp = path.join(dir, `${record.id}.json`)
|
|
72
62
|
atomicWriteJson(fp, record)
|
|
73
63
|
const indexPath = path.join(dir, "index.json")
|
|
74
|
-
let index =
|
|
64
|
+
let index = readJson(indexPath, [])
|
|
75
65
|
if (!Array.isArray(index)) index = []
|
|
76
66
|
const idx = index.findIndex(e => e?.id === record.id)
|
|
77
67
|
const entry = buildEntry(cls, record)
|
|
@@ -83,7 +73,7 @@ function upsertMistake(record) {
|
|
|
83
73
|
const dir = classDir("mistake")
|
|
84
74
|
fs.mkdirSync(dir, { recursive: true })
|
|
85
75
|
const fp = path.join(dir, "mistakes.jsonl")
|
|
86
|
-
let entries =
|
|
76
|
+
let entries = readJsonl(fp)
|
|
87
77
|
const idx = entries.findIndex(e => e?.id === record.id)
|
|
88
78
|
if (idx >= 0) entries[idx] = record; else entries.push(record)
|
|
89
79
|
const text = entries.map(e => stableStringify(e)).join("\n")
|
|
@@ -92,18 +82,18 @@ function upsertMistake(record) {
|
|
|
92
82
|
|
|
93
83
|
function queryList(cls, limit = 10) {
|
|
94
84
|
if (cls === "mistake") {
|
|
95
|
-
return sortRecent(filterActive(
|
|
85
|
+
return sortRecent(filterActive(readJsonl(path.join(classDir(cls), "mistakes.jsonl")))).slice(0, limit)
|
|
96
86
|
}
|
|
97
87
|
const dir = classDir(cls)
|
|
98
88
|
let files = []
|
|
99
89
|
try { files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && f !== "index.json").map(f => path.join(dir, f)) } catch { return [] }
|
|
100
|
-
const entries = files.map(f =>
|
|
90
|
+
const entries = files.map(f => readJson(f, null)).filter(Boolean).map(r => buildEntry(cls, r))
|
|
101
91
|
return sortRecent(filterActive(entries)).slice(0, limit)
|
|
102
92
|
}
|
|
103
93
|
|
|
104
94
|
function queryGet(cls, id) {
|
|
105
|
-
if (cls === "mistake") return
|
|
106
|
-
return
|
|
95
|
+
if (cls === "mistake") return readJsonl(path.join(classDir(cls), "mistakes.jsonl")).find(e => e?.id === id) ?? null
|
|
96
|
+
return readJson(path.join(classDir(cls), `${id}.json`), null)
|
|
107
97
|
}
|
|
108
98
|
|
|
109
99
|
function scoreRelevance(r, query, project) {
|
|
@@ -132,61 +122,67 @@ function enforceAuditEvidence(record) {
|
|
|
132
122
|
return ["db_refs", "file_refs", "log_refs"].some(k => Array.isArray(prov[k]) && prov[k].some(i => typeof i === "string" && i.trim()))
|
|
133
123
|
}
|
|
134
124
|
|
|
135
|
-
function setToolTitle(context, title
|
|
125
|
+
function setToolTitle(context, title) {
|
|
136
126
|
if (!context || typeof context.metadata !== "function") return
|
|
137
|
-
context.metadata({ title
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function classifyPutTitle(cls, id) {
|
|
141
|
-
const label = String(id || "").trim() || "unnamed"
|
|
142
|
-
if (cls === "checkpoint") return `Save checkpoint: ${label}`
|
|
143
|
-
if (cls === "verification_receipt") return `Save verification receipt: ${label}`
|
|
144
|
-
return `Save ${cls}: ${label}`
|
|
127
|
+
context.metadata({ title })
|
|
145
128
|
}
|
|
146
129
|
|
|
147
|
-
function
|
|
130
|
+
function handleAdd(cls, id, dataStr) {
|
|
148
131
|
let parsed
|
|
149
|
-
try { parsed = JSON.parse(dataStr) } catch (e) { return `
|
|
132
|
+
try { parsed = JSON.parse(dataStr) } catch (e) { return `invalid JSON: ${e.message}` }
|
|
150
133
|
if (!isPlainObject(parsed)) return "data must be a JSON object"
|
|
151
134
|
if (!id?.trim()) return "non-blank id is required"
|
|
152
135
|
|
|
153
136
|
const now = new Date().toISOString()
|
|
154
|
-
const
|
|
137
|
+
const existing = queryGet(cls, id.trim())
|
|
138
|
+
const record = { ...parsed, id, class: cls, source: parsed.source ?? "agent", status: parsed.status ?? "active", created_at: parsed.created_at ?? (existing?.created_at ?? now), updated_at: now }
|
|
155
139
|
|
|
156
|
-
const schema =
|
|
140
|
+
const schema = readJson(path.join(SCHEMAS_DIR, `${cls}.schema.json`), null)
|
|
157
141
|
if (schema) {
|
|
142
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
143
|
+
if (required.includes("provenance")) {
|
|
144
|
+
if (!record.provenance) record.provenance = { session_id: `auto-${now}` }
|
|
145
|
+
else if (!record.provenance.session_id) record.provenance.session_id = `auto-${now}`
|
|
146
|
+
}
|
|
147
|
+
if (required.includes("trigger") && !record.trigger) record.trigger = "manual"
|
|
148
|
+
if (required.includes("success_count") && record.success_count == null) record.success_count = 0
|
|
149
|
+
if (required.includes("failure_count") && record.failure_count == null) record.failure_count = 0
|
|
150
|
+
if (required.includes("promotion_state") && !record.promotion_state) record.promotion_state = "project"
|
|
151
|
+
|
|
158
152
|
const unsupported = findUnsupportedSchemaKeywords(schema)
|
|
159
|
-
if (unsupported.length) return `
|
|
153
|
+
if (unsupported.length) return `unsupported schema: ${unsupported.join(", ")}`
|
|
160
154
|
const errs = validateSchema(schema, record, "$")
|
|
161
155
|
if (cls === "audit" && !enforceAuditEvidence(record)) errs.push("$.provenance must include at least one non-empty evidence ref")
|
|
162
|
-
if (errs.length) return `
|
|
156
|
+
if (errs.length) return `validation: ${errs.join("; ")}`
|
|
163
157
|
}
|
|
164
158
|
|
|
165
159
|
if (cls === "mistake") upsertMistake(record)
|
|
166
160
|
else writeObject(cls, record)
|
|
167
161
|
|
|
168
|
-
|
|
162
|
+
const action = existing ? "updated" : "saved"
|
|
163
|
+
return `${action}: ${id.trim()}`
|
|
169
164
|
}
|
|
170
165
|
|
|
171
|
-
function
|
|
166
|
+
function handleFetch(cls, id) {
|
|
172
167
|
if (!id?.trim()) return "non-blank id is required"
|
|
173
168
|
const record = queryGet(cls, id.trim())
|
|
174
|
-
if (!record) return
|
|
175
|
-
return stableStringify(
|
|
169
|
+
if (!record) return `not found: ${id}`
|
|
170
|
+
return stableStringify(record)
|
|
176
171
|
}
|
|
177
172
|
|
|
178
173
|
function handleList(cls, limit = 10) {
|
|
179
174
|
const entries = queryList(cls, Math.min(limit, 100))
|
|
180
|
-
|
|
175
|
+
const lines = entries.map(e => ` ${e.id}: ${truncateText(e.summary || "", 80)}`)
|
|
176
|
+
return `${entries.length} ${cls} record${entries.length === 1 ? "" : "s"}\n` + lines.join("\n")
|
|
181
177
|
}
|
|
182
178
|
|
|
183
179
|
function handleLatest(cls) {
|
|
184
180
|
const list = queryList(cls, 100)
|
|
185
181
|
const active = filterActive(list)
|
|
186
|
-
if (!active[0]?.id) return
|
|
182
|
+
if (!active[0]?.id) return "no active records"
|
|
187
183
|
const record = queryGet(cls, active[0].id)
|
|
188
|
-
if (!record) return
|
|
189
|
-
return stableStringify(
|
|
184
|
+
if (!record) return "no active records"
|
|
185
|
+
return stableStringify(record)
|
|
190
186
|
}
|
|
191
187
|
|
|
192
188
|
function handleSearch(query, scope, classes, project, limit) {
|
|
@@ -197,7 +193,7 @@ function handleSearch(query, scope, classes, project, limit) {
|
|
|
197
193
|
let records = []
|
|
198
194
|
for (const cls of clsList) {
|
|
199
195
|
if (cls === "mistake") {
|
|
200
|
-
for (const m of
|
|
196
|
+
for (const m of readJsonl(path.join(classDir(cls), "mistakes.jsonl"))) {
|
|
201
197
|
if (!hasExpired(m)) records.push(m)
|
|
202
198
|
}
|
|
203
199
|
} else {
|
|
@@ -205,7 +201,7 @@ function handleSearch(query, scope, classes, project, limit) {
|
|
|
205
201
|
let files = []
|
|
206
202
|
try { files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && f !== "index.json") } catch { continue }
|
|
207
203
|
for (const f of files) {
|
|
208
|
-
const r =
|
|
204
|
+
const r = readJson(path.join(dir, f), null)
|
|
209
205
|
if (r && !hasExpired(r)) records.push(r)
|
|
210
206
|
}
|
|
211
207
|
}
|
|
@@ -216,7 +212,19 @@ function handleSearch(query, scope, classes, project, limit) {
|
|
|
216
212
|
.filter(e => e.score > 0)
|
|
217
213
|
.sort((a, b) => b.score - a.score)
|
|
218
214
|
.slice(0, lim)
|
|
219
|
-
|
|
215
|
+
const lines = scored.map(e => ` ${e.id} (${e.score}pt): ${truncateText(e.summary || "", 80)}`)
|
|
216
|
+
return `${scored.length} result${scored.length === 1 ? "" : "s"} for '${q}'\n` + lines.join("\n")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function handleArchive(cls, id) {
|
|
220
|
+
if (!id?.trim()) return "non-blank id is required"
|
|
221
|
+
const record = queryGet(cls, id.trim())
|
|
222
|
+
if (!record) return `not found: ${id}`
|
|
223
|
+
record.status = "expired"
|
|
224
|
+
record.updated_at = new Date().toISOString()
|
|
225
|
+
if (cls === "mistake") upsertMistake(record)
|
|
226
|
+
else writeObject(cls, record)
|
|
227
|
+
return `archived: ${id.trim()}`
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
export const MemoryToolsPlugin = async () => {
|
|
@@ -225,56 +233,56 @@ export const MemoryToolsPlugin = async () => {
|
|
|
225
233
|
|
|
226
234
|
return {
|
|
227
235
|
tool: {
|
|
228
|
-
|
|
229
|
-
description: "
|
|
236
|
+
add_memory: tool({
|
|
237
|
+
description: "Save a new memory record or update an existing one by class and id",
|
|
230
238
|
args: {
|
|
231
239
|
class: tool.schema.enum(CLASSES),
|
|
232
240
|
id: tool.schema.string(),
|
|
233
241
|
data: tool.schema.string(),
|
|
234
242
|
},
|
|
235
243
|
async execute(args, context) {
|
|
236
|
-
setToolTitle(context,
|
|
237
|
-
return
|
|
244
|
+
setToolTitle(context, `save ${args.class}: ${args.id}`)
|
|
245
|
+
return handleAdd(args.class, args.id, args.data)
|
|
238
246
|
},
|
|
239
247
|
}),
|
|
240
248
|
|
|
241
|
-
|
|
242
|
-
description: "Get a specific
|
|
249
|
+
fetch_memory: tool({
|
|
250
|
+
description: "Get a specific memory record by class and id",
|
|
243
251
|
args: {
|
|
244
252
|
class: tool.schema.enum(CLASSES).describe("Memory class"),
|
|
245
253
|
id: tool.schema.string().describe("Record ID"),
|
|
246
254
|
},
|
|
247
255
|
async execute(args, context) {
|
|
248
|
-
setToolTitle(context, `
|
|
249
|
-
return
|
|
256
|
+
setToolTitle(context, `fetch ${args.class}: ${args.id}`)
|
|
257
|
+
return handleFetch(args.class, args.id)
|
|
250
258
|
},
|
|
251
259
|
}),
|
|
252
260
|
|
|
253
|
-
|
|
254
|
-
description: "List
|
|
261
|
+
list_memory: tool({
|
|
262
|
+
description: "List recent memory records by class, sorted by recency",
|
|
255
263
|
args: {
|
|
256
264
|
class: tool.schema.enum(CLASSES).describe("Memory class"),
|
|
257
265
|
limit: tool.schema.number().optional().default(10).describe("Max results (max 100)"),
|
|
258
266
|
},
|
|
259
267
|
async execute(args, context) {
|
|
260
|
-
setToolTitle(context, `
|
|
268
|
+
setToolTitle(context, `list ${args.class}`)
|
|
261
269
|
return handleList(args.class, args.limit)
|
|
262
270
|
},
|
|
263
271
|
}),
|
|
264
272
|
|
|
265
|
-
|
|
266
|
-
description: "Get the latest active
|
|
273
|
+
latest_memory: tool({
|
|
274
|
+
description: "Get the latest active memory record by class",
|
|
267
275
|
args: {
|
|
268
276
|
class: tool.schema.enum(CLASSES).describe("Memory class"),
|
|
269
277
|
},
|
|
270
278
|
async execute(args, context) {
|
|
271
|
-
setToolTitle(context, `
|
|
279
|
+
setToolTitle(context, `latest ${args.class}`)
|
|
272
280
|
return handleLatest(args.class)
|
|
273
281
|
},
|
|
274
282
|
}),
|
|
275
283
|
|
|
276
|
-
|
|
277
|
-
description: "Search
|
|
284
|
+
search_memory: tool({
|
|
285
|
+
description: "Search memory records with keyword matching and relevance ranking across all classes",
|
|
278
286
|
args: {
|
|
279
287
|
query: tool.schema.string().describe("Search query string"),
|
|
280
288
|
scope: tool.schema.enum(["global", "local", "auto"]).optional().default("auto").describe("Search scope"),
|
|
@@ -283,17 +291,22 @@ export const MemoryToolsPlugin = async () => {
|
|
|
283
291
|
limit: tool.schema.number().optional().default(10).describe("Max results (max 50)"),
|
|
284
292
|
},
|
|
285
293
|
async execute(args, context) {
|
|
286
|
-
setToolTitle(context, `
|
|
287
|
-
action: "search",
|
|
288
|
-
scope: args.scope,
|
|
289
|
-
classes: args.classes?.length ? args.classes : CLASSES,
|
|
290
|
-
project: args.project ?? null,
|
|
291
|
-
limit: args.limit,
|
|
292
|
-
})
|
|
294
|
+
setToolTitle(context, `search: ${truncateText(args.query, 48)}`)
|
|
293
295
|
return handleSearch(args.query, args.scope, args.classes, args.project, args.limit)
|
|
294
296
|
},
|
|
295
297
|
}),
|
|
296
298
|
|
|
299
|
+
archive_memory: tool({
|
|
300
|
+
description: "Soft-delete a memory record by setting its status to expired",
|
|
301
|
+
args: {
|
|
302
|
+
class: tool.schema.enum(CLASSES),
|
|
303
|
+
id: tool.schema.string(),
|
|
304
|
+
},
|
|
305
|
+
async execute(args, context) {
|
|
306
|
+
setToolTitle(context, `archive ${args.class}: ${args.id}`)
|
|
307
|
+
return handleArchive(args.class, args.id)
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
297
310
|
},
|
|
298
311
|
}
|
|
299
312
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "ohc.json")
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = { enabled: true, max: 200000, min: 50000 }
|
|
8
|
+
|
|
9
|
+
function writeDefaults() {
|
|
10
|
+
try {
|
|
11
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true })
|
|
12
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf8")
|
|
13
|
+
} catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
let raw = {}
|
|
18
|
+
try {
|
|
19
|
+
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"))
|
|
20
|
+
} catch {
|
|
21
|
+
writeDefaults()
|
|
22
|
+
return { ...DEFAULTS }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
enabled: raw.enabled !== false,
|
|
27
|
+
max: typeof raw.max === "number" && raw.max > 0 ? raw.max : DEFAULTS.max,
|
|
28
|
+
min: typeof raw.min === "number" ? Math.max(10000, raw.min) : DEFAULTS.min,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { loadConfig } from "./config.mjs"
|
|
3
|
+
import { selectMessagesToReap, totalTokens } from "./reaper.mjs"
|
|
4
|
+
import { loadOhcState, saveOhcState } from "./state.mjs"
|
|
5
|
+
|
|
6
|
+
function buildNudge(pct, max) {
|
|
7
|
+
if (pct > 0.95) return `[OHC] Context critically high (${Math.round(pct * 100)}% of ${max.toLocaleString()} token budget). Oldest messages will be pruned immediately if limit exceeded. Use the \`compress\` tool now.`
|
|
8
|
+
if (pct > 0.85) return `[OHC] Context at ${Math.round(pct * 100)}%. Proactive compression recommended. Run \`compress\` to free space.`
|
|
9
|
+
if (pct > 0.70) return `[OHC] Context at ${Math.round(pct * 100)}% of budget. Consider using \`compress\` to keep room for new content.`
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarizeRemoved(selected, summary) {
|
|
14
|
+
const n = selected.length
|
|
15
|
+
if (summary) return `[Compressed: ${summary} — ${n} message${n === 1 ? "" : "s"} removed]`
|
|
16
|
+
return `[Auto-pruned: ${n} message${n === 1 ? "" : "s"} removed]`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createSummaryMessage(text) {
|
|
20
|
+
return { parts: [{ type: "text", text }], info: { role: "system" } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function applyCompress(ctx, sessionId, summary, max, min, targetTokens) {
|
|
24
|
+
const ss = getOrCreateState(sessionId)
|
|
25
|
+
ss.prunedIds.clear()
|
|
26
|
+
|
|
27
|
+
const res = await ctx.client.session.messages({ path: { id: sessionId } })
|
|
28
|
+
const msgs = res?.data || res || []
|
|
29
|
+
if (!Array.isArray(msgs)) return { removed: 0, message: "no messages" }
|
|
30
|
+
|
|
31
|
+
const selected = selectMessagesToReap(msgs, max, min, "compress", targetTokens)
|
|
32
|
+
if (selected.length === 0) return { removed: 0, message: "already within target" }
|
|
33
|
+
|
|
34
|
+
for (const r of selected) ss.prunedIds.add(r.id)
|
|
35
|
+
ss.summary = summarizeRemoved(selected, summary)
|
|
36
|
+
ss.anchorMessageId = selected[0].id
|
|
37
|
+
saveOhcState(sessionId, {
|
|
38
|
+
prunedMessageIds: [...ss.prunedIds],
|
|
39
|
+
summary: ss.summary,
|
|
40
|
+
anchorMessageId: ss.anchorMessageId,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { removed: selected.length }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stateCache = new Map()
|
|
47
|
+
|
|
48
|
+
function getOrCreateState(sessionId) {
|
|
49
|
+
if (!sessionId) return null
|
|
50
|
+
let s = stateCache.get(sessionId)
|
|
51
|
+
if (!s) {
|
|
52
|
+
const persisted = loadOhcState(sessionId)
|
|
53
|
+
s = {
|
|
54
|
+
prunedIds: new Set(persisted?.prunedMessageIds || []),
|
|
55
|
+
summary: persisted?.summary || null,
|
|
56
|
+
anchorMessageId: persisted?.anchorMessageId || null,
|
|
57
|
+
lastNudgePct: 0,
|
|
58
|
+
}
|
|
59
|
+
stateCache.set(sessionId, s)
|
|
60
|
+
}
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const OhcPlugin = async (ctx) => {
|
|
65
|
+
const config = loadConfig()
|
|
66
|
+
if (!config.enabled) return {}
|
|
67
|
+
|
|
68
|
+
const max = config.max
|
|
69
|
+
const min = config.min
|
|
70
|
+
if (max <= min + 10000) return {}
|
|
71
|
+
|
|
72
|
+
let systemInjected = false
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
76
|
+
if (systemInjected || !output.system?.length) return
|
|
77
|
+
systemInjected = true
|
|
78
|
+
output.system[output.system.length - 1] += `\n\n## Context Management (OHC)\n- OHC manages all compression. Set \`compaction.auto: false\` in opencode.json to prevent double pruning.\n- Default soft budget: ${max.toLocaleString()} tokens. Soft floor: ${min.toLocaleString()}.\n- These are advisory — the agent can override by passing \`targetTokens\` to the \`compress\` tool.\n- Call \`compress\` with a summary to free context space. Optionally specify \`targetTokens\` (lower = more aggressive).`
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
82
|
+
if (!output?.messages?.length) return
|
|
83
|
+
|
|
84
|
+
const sessionId = output.messages.find(m => m.info?.sessionID)?.info?.sessionID
|
|
85
|
+
if (!sessionId) return
|
|
86
|
+
|
|
87
|
+
const ss = getOrCreateState(sessionId)
|
|
88
|
+
if (!ss) return
|
|
89
|
+
|
|
90
|
+
if (ss.prunedIds.size > 0) {
|
|
91
|
+
const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
|
|
92
|
+
if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
|
|
93
|
+
ss.prunedIds.clear()
|
|
94
|
+
ss.summary = null
|
|
95
|
+
ss.anchorMessageId = null
|
|
96
|
+
saveOhcState(sessionId, {
|
|
97
|
+
prunedMessageIds: [],
|
|
98
|
+
summary: null,
|
|
99
|
+
anchorMessageId: null,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const currentTotal = totalTokens(output.messages)
|
|
105
|
+
if (currentTotal > max) {
|
|
106
|
+
const selected = selectMessagesToReap(output.messages, max, min)
|
|
107
|
+
if (selected.length > 0) {
|
|
108
|
+
for (const r of selected) ss.prunedIds.add(r.id)
|
|
109
|
+
if (!ss.summary) ss.summary = summarizeRemoved(selected, null)
|
|
110
|
+
if (!ss.anchorMessageId) ss.anchorMessageId = selected[0].id
|
|
111
|
+
saveOhcState(sessionId, {
|
|
112
|
+
prunedMessageIds: [...ss.prunedIds],
|
|
113
|
+
summary: ss.summary,
|
|
114
|
+
anchorMessageId: ss.anchorMessageId,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ss.prunedIds.size > 0) {
|
|
120
|
+
const prunedIds = ss.prunedIds
|
|
121
|
+
const summary = ss.summary
|
|
122
|
+
const anchorId = ss.anchorMessageId
|
|
123
|
+
const result = []
|
|
124
|
+
let injected = false
|
|
125
|
+
|
|
126
|
+
for (const msg of output.messages) {
|
|
127
|
+
const msgId = msg.info?.id
|
|
128
|
+
|
|
129
|
+
if (anchorId && msgId === anchorId && !injected && summary) {
|
|
130
|
+
result.push(createSummaryMessage(summary))
|
|
131
|
+
injected = true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (msgId !== undefined && prunedIds.has(msgId)) continue
|
|
135
|
+
|
|
136
|
+
result.push(msg)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!injected && summary && result.length > 1) {
|
|
140
|
+
result.splice(1, 0, createSummaryMessage(summary))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
output.messages.length = 0
|
|
144
|
+
output.messages.push(...result)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const afterTotal = totalTokens(output.messages)
|
|
148
|
+
const pct = afterTotal / max
|
|
149
|
+
const nudge = buildNudge(pct, max)
|
|
150
|
+
if (nudge && pct > ss.lastNudgePct + 0.05) {
|
|
151
|
+
ss.lastNudgePct = pct
|
|
152
|
+
for (let i = output.messages.length - 1; i >= 0; i--) {
|
|
153
|
+
const m = output.messages[i]
|
|
154
|
+
if (m.info?.role === "assistant" && m.parts?.length) {
|
|
155
|
+
const textPart = m.parts.find(p => p.type === "text")
|
|
156
|
+
if (textPart) { textPart.text += "\n\n" + nudge; break }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
"command.execute.before": async (input, output) => {
|
|
163
|
+
if (input.command !== "ohc") return
|
|
164
|
+
const sub = (input.arguments || "").trim().toLowerCase()
|
|
165
|
+
const args = (input.arguments || "").trim()
|
|
166
|
+
|
|
167
|
+
if (sub === "status") {
|
|
168
|
+
let msgs = [], t = 0
|
|
169
|
+
try {
|
|
170
|
+
if (ctx?.client?.session?.messages) {
|
|
171
|
+
const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
|
|
172
|
+
msgs = res?.data || res || []
|
|
173
|
+
t = totalTokens(msgs)
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
const ss = getOrCreateState(input.sessionID)
|
|
177
|
+
const prunedCount = ss?.prunedIds.size || 0
|
|
178
|
+
const text = `[OHC Status] ${msgs.length} messages visible (${prunedCount} pruned), ~${Math.round(t / 1000)}K / ${max.toLocaleString()} tokens (${Math.round((t / max) * 100)}%). Soft floor: ${min.toLocaleString()}.`
|
|
179
|
+
await ctx.client.session.prompt({
|
|
180
|
+
path: { id: input.sessionID },
|
|
181
|
+
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
182
|
+
})
|
|
183
|
+
throw new Error("__OHC_STATUS_HANDLED__")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (sub.startsWith("compress")) {
|
|
187
|
+
const rest = args.replace(/^compress\s*/i, "").trim()
|
|
188
|
+
const numMatch = rest.match(/^(\d+)\s*(.*)/)
|
|
189
|
+
let targetTokens
|
|
190
|
+
let focus
|
|
191
|
+
if (numMatch) {
|
|
192
|
+
targetTokens = parseInt(numMatch[1], 10)
|
|
193
|
+
focus = numMatch[2].trim() || "Manual compression by user"
|
|
194
|
+
} else {
|
|
195
|
+
focus = rest || "Manual compression by user"
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const result = await applyCompress(ctx, input.sessionID, focus, max, min, targetTokens)
|
|
199
|
+
output.parts.length = 0
|
|
200
|
+
output.parts.push({
|
|
201
|
+
type: "text",
|
|
202
|
+
text: `[OHC] Compressed: ${result.removed} messages removed. Summary: ${focus}`,
|
|
203
|
+
})
|
|
204
|
+
} catch {
|
|
205
|
+
output.parts.length = 0
|
|
206
|
+
output.parts.push({ type: "text", text: `/ohc compress ${focus}` })
|
|
207
|
+
}
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const text = "OHC commands: /ohc status — /ohc compress [targetTokens] [focus description]"
|
|
212
|
+
await ctx.client.session.prompt({
|
|
213
|
+
path: { id: input.sessionID },
|
|
214
|
+
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
215
|
+
})
|
|
216
|
+
throw new Error("__OHC_HELP_HANDLED__")
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
tool: {
|
|
220
|
+
compress: tool({
|
|
221
|
+
description: "Proactively compress old conversation content to free context space. Provide a technical summary of what was removed. Optionally specify targetTokens to control how much to keep (lower = more aggressive).",
|
|
222
|
+
args: {
|
|
223
|
+
summary: tool.schema.string().describe("Technical summary of the compressed content. Include what was removed and key decisions preserved."),
|
|
224
|
+
targetTokens: tool.schema.number().optional().describe("Estimated target after compression (heuristic, not exact). Lower = more aggressive. Default uses soft config floor."),
|
|
225
|
+
},
|
|
226
|
+
async execute(args, toolCtx) {
|
|
227
|
+
const result = await applyCompress(ctx, toolCtx.sessionID, args.summary, max, min, args.targetTokens)
|
|
228
|
+
toolCtx.metadata({ title: "Compress" })
|
|
229
|
+
return `Compressed: ${result.removed} messages removed. Summary: "${truncateText(args.summary, 200)}"`
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function truncateText(s, n) {
|
|
237
|
+
if (!s || s.length <= n) return s || ""
|
|
238
|
+
return s.slice(0, n) + "..."
|
|
239
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function partTokens(part) {
|
|
2
|
+
if (part.type === "text") return Math.ceil((part.text || "").length / 4)
|
|
3
|
+
if (part.type === "tool") {
|
|
4
|
+
let t = 0
|
|
5
|
+
if (part.state?.input) t += JSON.stringify(part.state.input).length / 4
|
|
6
|
+
if (part.state?.output)
|
|
7
|
+
t += (typeof part.state.output === "string" ? part.state.output : JSON.stringify(part.state.output ?? "")).length / 4
|
|
8
|
+
return Math.ceil(t)
|
|
9
|
+
}
|
|
10
|
+
return Math.ceil(JSON.stringify(part).length / 4)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function msgTokens(msg) {
|
|
14
|
+
return (Array.isArray(msg.parts) ? msg.parts : []).reduce((s, p) => s + partTokens(p), 0)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function totalTokens(messages) {
|
|
18
|
+
return (Array.isArray(messages) ? messages : []).reduce((s, m) => s + msgTokens(m), 0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Select oldest messages to remove.
|
|
23
|
+
* Returns array of { id, msg, tokens } without mutating the input.
|
|
24
|
+
* Never removes index 0 (system prompt) or the last message (latest turn).
|
|
25
|
+
*
|
|
26
|
+
* mode "auto": remove just enough to bring total under maxLimit
|
|
27
|
+
* mode "compress": remove everything down to floor (deep prune)
|
|
28
|
+
*
|
|
29
|
+
* targetOverride: when set, replaces floor/minFloor — agent explicit request
|
|
30
|
+
* overrides the soft config defaults. Used when user asks "compress to X".
|
|
31
|
+
*/
|
|
32
|
+
export function selectMessagesToReap(messages, maxLimit, minFloor, mode = "auto", targetOverride) {
|
|
33
|
+
if (!messages?.length || messages.length < 3) return []
|
|
34
|
+
|
|
35
|
+
let total = totalTokens(messages)
|
|
36
|
+
const selected = []
|
|
37
|
+
|
|
38
|
+
if (mode === "compress") {
|
|
39
|
+
const floor = targetOverride ?? minFloor
|
|
40
|
+
let i = 1
|
|
41
|
+
while (i < messages.length - 1) {
|
|
42
|
+
const t = msgTokens(messages[i])
|
|
43
|
+
if (total - t < floor) break
|
|
44
|
+
total -= t
|
|
45
|
+
selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const floor = targetOverride ?? minFloor
|
|
50
|
+
let i = 1
|
|
51
|
+
while (i < messages.length - 1 && total > maxLimit) {
|
|
52
|
+
const t = msgTokens(messages[i])
|
|
53
|
+
if (total - t < floor) break
|
|
54
|
+
total -= t
|
|
55
|
+
selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return selected
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const STATE_DIR = path.join(os.homedir(), ".local", "share", "opencode")
|
|
6
|
+
const STATE_FILE = path.join(STATE_DIR, "ohc-state.json")
|
|
7
|
+
|
|
8
|
+
function readAll() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
|
|
11
|
+
} catch {
|
|
12
|
+
return {}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeAll(data) {
|
|
17
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
18
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), "utf8")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadOhcState(sessionId) {
|
|
22
|
+
if (!sessionId) return null
|
|
23
|
+
const all = readAll()
|
|
24
|
+
return all[sessionId] || null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveOhcState(sessionId, data) {
|
|
28
|
+
if (!sessionId) return
|
|
29
|
+
const all = readAll()
|
|
30
|
+
all[sessionId] = { ...data, updatedAt: new Date().toISOString() }
|
|
31
|
+
writeAll(all)
|
|
32
|
+
}
|