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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -111
  3. package/autorecall.mjs +2 -12
  4. package/bootstrap.mjs +160 -8
  5. package/curator.mjs +1 -5
  6. package/harness/commands/checkpoint.md +68 -0
  7. package/harness/commands/eval.md +89 -0
  8. package/harness/commands/go-build.md +87 -0
  9. package/harness/commands/go-review.md +71 -0
  10. package/harness/commands/harness-audit.md +90 -0
  11. package/harness/commands/learn.md +2 -2
  12. package/harness/commands/loop-start.md +38 -0
  13. package/harness/commands/loop-status.md +30 -0
  14. package/harness/commands/memory-search.md +2 -2
  15. package/harness/commands/model-route.md +32 -0
  16. package/harness/commands/ohc.md +13 -0
  17. package/harness/commands/orchestrate.md +88 -0
  18. package/harness/commands/quality-gate.md +35 -0
  19. package/harness/commands/refactor-clean.md +102 -0
  20. package/harness/commands/rust-build.md +78 -0
  21. package/harness/commands/rust-review.md +65 -0
  22. package/harness/commands/setup-pm.md +65 -0
  23. package/harness/commands/skill-create.md +99 -0
  24. package/harness/commands/test-coverage.md +80 -0
  25. package/harness/commands/update-codemaps.md +81 -0
  26. package/harness/commands/update-docs.md +67 -0
  27. package/harness/commands/verify.md +68 -0
  28. package/harness/instructions/CONVENTIONS.md +206 -0
  29. package/harness/instructions/RUNTIME.md +8 -1
  30. package/harness/prompts/build-cpp.md +84 -0
  31. package/harness/prompts/build-error-resolver.md +2 -1
  32. package/harness/prompts/build-go.md +326 -0
  33. package/harness/prompts/build-java.md +126 -0
  34. package/harness/prompts/build-kotlin.md +123 -0
  35. package/harness/prompts/build-rust.md +94 -0
  36. package/harness/prompts/code-reviewer.md +2 -1
  37. package/harness/prompts/doc-updater.md +193 -0
  38. package/harness/prompts/docs-lookup.md +60 -0
  39. package/harness/prompts/explore.md +1 -0
  40. package/harness/prompts/harness-optimizer.md +30 -0
  41. package/harness/prompts/loop-operator.md +42 -0
  42. package/harness/prompts/planner.md +3 -2
  43. package/harness/prompts/refactor-cleaner.md +242 -0
  44. package/harness/prompts/review-cpp.md +68 -0
  45. package/harness/prompts/review-database.md +248 -0
  46. package/harness/prompts/review-go.md +244 -0
  47. package/harness/prompts/review-java.md +100 -0
  48. package/harness/prompts/review-kotlin.md +130 -0
  49. package/harness/prompts/review-python.md +88 -0
  50. package/harness/prompts/review-rust.md +64 -0
  51. package/harness/prompts/security-reviewer.md +3 -2
  52. package/harness/prompts/tdd-guide.md +214 -0
  53. package/harness/rules/delegation.md +28 -22
  54. package/harness/rules/memory-management.md +4 -4
  55. package/harness/rules/retrieval.md +5 -5
  56. package/harness/rules/runtime-guards.md +1 -1
  57. package/harness/rules/session-start.md +4 -4
  58. package/harness/rules/skills-management.md +2 -2
  59. package/harness/rules/state-drift.md +1 -1
  60. package/harness/rules/verification.md +4 -4
  61. package/harness/scripts/sync-commands.mjs +259 -0
  62. package/harness/skills/coding-standards/SKILL.md +1 -1
  63. package/index.mjs +25 -4
  64. package/lib/hardening.mjs +11 -1
  65. package/lib/memory-tools-plugin.mjs +84 -71
  66. package/lib/ohc/config.mjs +30 -0
  67. package/lib/ohc/pruner.mjs +239 -0
  68. package/lib/ohc/reaper.mjs +61 -0
  69. package/lib/ohc/state.mjs +32 -0
  70. package/lib/ohc/updater.mjs +110 -0
  71. package/package.json +6 -2
  72. 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 "../lib/paths.mjs"
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 = readJSON(indexPath, [])
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 = readJSONL(fp)
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(readJSONL(path.join(classDir(cls), "mistakes.jsonl")))).slice(0, limit)
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 => readJSON(f, null)).filter(Boolean).map(r => buildEntry(cls, r))
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 readJSONL(path.join(classDir(cls), "mistakes.jsonl")).find(e => e?.id === id) ?? null
106
- return readJSON(path.join(classDir(cls), `${id}.json`), null)
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, metadata = {}) {
125
+ function setToolTitle(context, title) {
136
126
  if (!context || typeof context.metadata !== "function") return
137
- context.metadata({ title, metadata })
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 handlePut(cls, id, dataStr) {
130
+ function handleAdd(cls, id, dataStr) {
148
131
  let parsed
149
- try { parsed = JSON.parse(dataStr) } catch (e) { return `data must be valid JSON: ${e.message}` }
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 record = { ...parsed, id, class: cls, source: parsed.source ?? "agent", status: parsed.status ?? "active", created_at: parsed.created_at ?? now, updated_at: now }
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 = readJSON(path.join(SCHEMAS_DIR, `${cls}.schema.json`), null)
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 `Unsupported schema keywords: ${unsupported.join(", ")}`
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 `Validation errors: ${errs.join("; ")}`
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
- return stableStringify({ ok: true, id })
162
+ const action = existing ? "updated" : "saved"
163
+ return `${action}: ${id.trim()}`
169
164
  }
170
165
 
171
- function handleGet(cls, id) {
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 stableStringify({ ok: false, found: false })
175
- return stableStringify({ ok: true, record })
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
- return stableStringify({ ok: true, count: entries.length, entries })
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 stableStringify({ ok: false, found: false })
182
+ if (!active[0]?.id) return "no active records"
187
183
  const record = queryGet(cls, active[0].id)
188
- if (!record) return stableStringify({ ok: false, found: false })
189
- return stableStringify({ ok: true, record })
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 readJSONL(path.join(classDir(cls), "mistakes.jsonl"))) {
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 = readJSON(path.join(dir, f), null)
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
- return stableStringify({ ok: true, count: scored.length, query: q, results: scored })
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
- hm_put: tool({
229
- description: "Create or update an OpenHermes memory record",
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, classifyPutTitle(args.class, args.id), { action: "put", class: args.class, id: args.id })
237
- return handlePut(args.class, args.id, args.data)
244
+ setToolTitle(context, `save ${args.class}: ${args.id}`)
245
+ return handleAdd(args.class, args.id, args.data)
238
246
  },
239
247
  }),
240
248
 
241
- hm_get: tool({
242
- description: "Get a specific OpenHermes memory record by ID",
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, `Open ${args.class}: ${args.id}`, { action: "get", class: args.class, id: args.id })
249
- return handleGet(args.class, args.id)
256
+ setToolTitle(context, `fetch ${args.class}: ${args.id}`)
257
+ return handleFetch(args.class, args.id)
250
258
  },
251
259
  }),
252
260
 
253
- hm_list: tool({
254
- description: "List OpenHermes memory records by class, sorted by recency",
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, `List ${args.class} records`, { action: "list", class: args.class, limit: args.limit })
268
+ setToolTitle(context, `list ${args.class}`)
261
269
  return handleList(args.class, args.limit)
262
270
  },
263
271
  }),
264
272
 
265
- hm_latest: tool({
266
- description: "Get the latest active OpenHermes memory record by class",
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, `Latest ${args.class}`, { action: "latest", class: args.class })
279
+ setToolTitle(context, `latest ${args.class}`)
272
280
  return handleLatest(args.class)
273
281
  },
274
282
  }),
275
283
 
276
- hm_search: tool({
277
- description: "Search OpenHermes memory records with keyword matching and relevance ranking",
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, `Search memory: ${truncateText(args.query, 48)}`, {
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
+ }