openhermes 2.8.0 → 4.0.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.
Files changed (158) hide show
  1. package/CONTEXT.md +18 -0
  2. package/ETHOS.md +15 -0
  3. package/README.md +135 -292
  4. package/bootstrap.mjs +174 -512
  5. package/harness/agents/openhermes.md +87 -0
  6. package/harness/codex/CONSTITUTION.md +70 -148
  7. package/harness/codex/ROUTING.md +126 -0
  8. package/harness/commands/oh-doctor.md +26 -0
  9. package/harness/instructions/CONVENTIONS.md +206 -206
  10. package/harness/instructions/RUNTIME.md +54 -31
  11. package/harness/skills/oh-builder/SKILL.md +98 -0
  12. package/harness/skills/oh-caveman/SKILL.md +33 -0
  13. package/harness/skills/oh-expert/SKILL.md +121 -0
  14. package/harness/skills/oh-freeze/SKILL.md +28 -0
  15. package/harness/skills/oh-gauntlet/SKILL.md +119 -0
  16. package/harness/skills/oh-grill/SKILL.md +77 -0
  17. package/harness/skills/oh-guard/SKILL.md +33 -0
  18. package/harness/skills/oh-handoff/SKILL.md +33 -0
  19. package/harness/skills/oh-health/SKILL.md +90 -0
  20. package/harness/skills/oh-init/SKILL.md +78 -0
  21. package/harness/skills/oh-investigate/SKILL.md +35 -0
  22. package/harness/skills/oh-issue/SKILL.md +36 -0
  23. package/harness/skills/oh-learn/SKILL.md +28 -0
  24. package/harness/skills/oh-manifest/SKILL.md +84 -0
  25. package/harness/skills/oh-plan-review/SKILL.md +128 -0
  26. package/harness/skills/oh-planner/SKILL.md +157 -0
  27. package/harness/skills/oh-prd/SKILL.md +35 -0
  28. package/harness/skills/oh-retro/SKILL.md +33 -0
  29. package/harness/skills/oh-review/SKILL.md +110 -0
  30. package/harness/skills/oh-security/SKILL.md +110 -0
  31. package/harness/skills/oh-ship/SKILL.md +39 -0
  32. package/harness/skills/oh-skill-craft/SKILL.md +107 -0
  33. package/harness/skills/oh-skills-link/SKILL.md +29 -0
  34. package/harness/skills/oh-skills-list/SKILL.md +31 -0
  35. package/harness/skills/oh-triage/SKILL.md +36 -0
  36. package/index.mjs +3 -60
  37. package/lib/harness-resolver.mjs +77 -0
  38. package/lib/logger.mjs +62 -0
  39. package/package.json +49 -53
  40. package/test/plugins-behavioral.test.mjs +64 -0
  41. package/test/plugins.test.mjs +62 -0
  42. package/autorecall.mjs +0 -237
  43. package/curator.mjs +0 -482
  44. package/harness/commands/build-fix.md +0 -60
  45. package/harness/commands/checkpoint.md +0 -68
  46. package/harness/commands/code-review.md +0 -71
  47. package/harness/commands/doctor.md +0 -42
  48. package/harness/commands/eval.md +0 -89
  49. package/harness/commands/go-build.md +0 -87
  50. package/harness/commands/go-review.md +0 -71
  51. package/harness/commands/harness-audit.md +0 -90
  52. package/harness/commands/learn.md +0 -37
  53. package/harness/commands/loop-start.md +0 -38
  54. package/harness/commands/loop-status.md +0 -30
  55. package/harness/commands/memory-search.md +0 -37
  56. package/harness/commands/model-route.md +0 -32
  57. package/harness/commands/ohc.md +0 -13
  58. package/harness/commands/orchestrate.md +0 -88
  59. package/harness/commands/plan.md +0 -53
  60. package/harness/commands/quality-gate.md +0 -35
  61. package/harness/commands/refactor-clean.md +0 -102
  62. package/harness/commands/rust-build.md +0 -78
  63. package/harness/commands/rust-review.md +0 -65
  64. package/harness/commands/security.md +0 -93
  65. package/harness/commands/setup-pm.md +0 -65
  66. package/harness/commands/skill-create.md +0 -99
  67. package/harness/commands/test-coverage.md +0 -80
  68. package/harness/commands/update-codemaps.md +0 -81
  69. package/harness/commands/update-docs.md +0 -67
  70. package/harness/commands/verify.md +0 -68
  71. package/harness/prompts/architect.txt +0 -189
  72. package/harness/prompts/build-cpp.md +0 -98
  73. package/harness/prompts/build-error-resolver.md +0 -44
  74. package/harness/prompts/build-go.md +0 -340
  75. package/harness/prompts/build-java.md +0 -140
  76. package/harness/prompts/build-kotlin.md +0 -137
  77. package/harness/prompts/build-rust.md +0 -108
  78. package/harness/prompts/code-reviewer.md +0 -40
  79. package/harness/prompts/doc-updater.md +0 -206
  80. package/harness/prompts/docs-lookup.md +0 -71
  81. package/harness/prompts/e2e-runner.txt +0 -317
  82. package/harness/prompts/explore.md +0 -42
  83. package/harness/prompts/harness-optimizer.md +0 -42
  84. package/harness/prompts/loop-operator.md +0 -53
  85. package/harness/prompts/planner.md +0 -37
  86. package/harness/prompts/refactor-cleaner.md +0 -256
  87. package/harness/prompts/review-cpp.md +0 -81
  88. package/harness/prompts/review-database.md +0 -261
  89. package/harness/prompts/review-go.md +0 -257
  90. package/harness/prompts/review-java.md +0 -113
  91. package/harness/prompts/review-kotlin.md +0 -143
  92. package/harness/prompts/review-python.md +0 -101
  93. package/harness/prompts/review-rust.md +0 -77
  94. package/harness/prompts/security-reviewer.md +0 -42
  95. package/harness/prompts/tdd-guide.md +0 -228
  96. package/harness/rules/audit.md +0 -84
  97. package/harness/rules/checkpointing.md +0 -75
  98. package/harness/rules/context-loading.md +0 -33
  99. package/harness/rules/credential-exposure.md +0 -0
  100. package/harness/rules/delegation.md +0 -80
  101. package/harness/rules/handoff.md +0 -267
  102. package/harness/rules/memory-management.md +0 -28
  103. package/harness/rules/precedence.md +0 -52
  104. package/harness/rules/promotion.md +0 -46
  105. package/harness/rules/ranking.md +0 -64
  106. package/harness/rules/retrieval.md +0 -94
  107. package/harness/rules/runtime-guards.md +0 -196
  108. package/harness/rules/self-heal.md +0 -79
  109. package/harness/rules/session-start.md +0 -34
  110. package/harness/rules/skills-management.md +0 -165
  111. package/harness/rules/state-drift.md +0 -192
  112. package/harness/rules/verification.md +0 -88
  113. package/harness/scripts/sync-commands.mjs +0 -259
  114. package/harness/skills/.bundled_manifest +0 -17
  115. package/harness/skills/.usage.json +0 -6
  116. package/harness/skills/api-design/SKILL.md +0 -523
  117. package/harness/skills/backend-patterns/SKILL.md +0 -598
  118. package/harness/skills/coding-standards/SKILL.md +0 -549
  119. package/harness/skills/e2e-testing/SKILL.md +0 -326
  120. package/harness/skills/frontend-patterns/SKILL.md +0 -642
  121. package/harness/skills/frontend-slides/SKILL.md +0 -184
  122. package/harness/skills/security-review/SKILL.md +0 -495
  123. package/harness/skills/strategic-compact/SKILL.md +0 -131
  124. package/harness/skills/tdd-workflow/SKILL.md +0 -463
  125. package/harness/skills/verification-loop/SKILL.md +0 -126
  126. package/lib/ambient-memory.mjs +0 -167
  127. package/lib/handoff.mjs +0 -171
  128. package/lib/hardening.mjs +0 -146
  129. package/lib/memory-tools-plugin.mjs +0 -368
  130. package/lib/ohc/block-sync.mjs +0 -69
  131. package/lib/ohc/compress/search.mjs +0 -152
  132. package/lib/ohc/compress/state.mjs +0 -76
  133. package/lib/ohc/config.mjs +0 -185
  134. package/lib/ohc/message-ids.mjs +0 -178
  135. package/lib/ohc/notify.mjs +0 -135
  136. package/lib/ohc/protected-patterns.mjs +0 -55
  137. package/lib/ohc/prune-apply.mjs +0 -134
  138. package/lib/ohc/pruner.mjs +0 -608
  139. package/lib/ohc/reaper.mjs +0 -70
  140. package/lib/ohc/state.mjs +0 -265
  141. package/lib/ohc/strategies/deduplication.mjs +0 -72
  142. package/lib/ohc/strategies/index.mjs +0 -2
  143. package/lib/ohc/strategies/purge-errors.mjs +0 -43
  144. package/lib/ohc/token-utils.mjs +0 -26
  145. package/lib/ohc/updater.mjs +0 -132
  146. package/lib/paths.mjs +0 -49
  147. package/lib/schema-validator.mjs +0 -79
  148. package/lib/search.mjs +0 -48
  149. package/schemas/audit.schema.json +0 -82
  150. package/schemas/backlog.schema.json +0 -63
  151. package/schemas/checkpoint.schema.json +0 -65
  152. package/schemas/constraint.schema.json +0 -62
  153. package/schemas/decision.schema.json +0 -63
  154. package/schemas/instinct.schema.json +0 -63
  155. package/schemas/loop-state.schema.json +0 -33
  156. package/schemas/mistake.schema.json +0 -64
  157. package/schemas/verification_receipt.schema.json +0 -88
  158. package/skill-builder.mjs +0 -88
@@ -1,608 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin"
2
- import { loadConfig } from "./config.mjs"
3
- import { selectMessagesToReap, totalTokens, msgTokens } from "./reaper.mjs"
4
- import {
5
- loadOhcState, saveOhcState, createSessionState,
6
- serializeState, deserializeState,
7
- buildToolIdList, syncToolCache, countTurns,
8
- } from "./state.mjs"
9
- import { sendCompressNotification } from "./notify.mjs"
10
- import { deduplicate, purgeErrors } from "./strategies/index.mjs"
11
- import { applyPruneTools, filterCompressedBlocks, applyFullToolRemoval } from "./prune-apply.mjs"
12
- import { assignMessageRefs, injectMessageIds, cleanupMessageRefs } from "./message-ids.mjs"
13
- import { syncCompressionBlocks } from "./block-sync.mjs"
14
- import { buildSearchContext, resolveBoundaryIds, resolveSelection, resolveAnchorMessageId, validateNonOverlapping } from "./compress/search.mjs"
15
- import { allocateBlockId, allocateRunId, wrapBlockSummary, applyCompressionState } from "./compress/state.mjs"
16
-
17
- const AUTOPRUNE_COOLDOWN = 10000
18
- const stateCache = new Map()
19
-
20
- function getOrCreateState(sessionId) {
21
- if (!sessionId) return null
22
- let s = stateCache.get(sessionId)
23
- if (!s) {
24
- const persisted = loadOhcState(sessionId)
25
- s = deserializeState(persisted)
26
- s.sessionId = sessionId
27
- stateCache.set(sessionId, s)
28
- }
29
- return s
30
- }
31
-
32
- function getSessionId(messages) {
33
- if (!messages?.length) return null
34
- for (const m of messages) {
35
- if (m.info?.sessionID) return m.info.sessionID
36
- }
37
- return null
38
- }
39
-
40
- function buildNudge(pct, max) {
41
- if (pct > 0.95) return `OHC: Context is nearly full \u2014 use \`compress\` now.`
42
- if (pct > 0.85) return `OHC: Context usage is high. Run \`compress\` to free space.`
43
- if (pct > 0.70) return `OHC: Context is growing. Consider \`compress\` to keep room.`
44
- return null
45
- }
46
-
47
- function summarizeRemoved(selected, summary) {
48
- const n = selected.length
49
- if (summary) return `[OHC: Compressed] ${summary} \u2014 ${n} message${n === 1 ? "" : "s"} removed`
50
- return `[OHC: Auto-pruned] ${n} message${n === 1 ? "" : "s"} removed`
51
- }
52
-
53
- function createSummaryMessage(text) {
54
- return { parts: [{ type: "text", text }], info: { role: "system" } }
55
- }
56
-
57
- async function applyCompress(ctx, sessionId, summary, max, min, targetTokens) {
58
- const ss = getOrCreateState(sessionId)
59
- if (ss) {
60
- ss.prunedIds.clear()
61
- ss.summary = null
62
- ss.anchorMessageId = null
63
- ss._pruneCycleDone = false
64
- ss.lastAutoPruneAt = null
65
- }
66
-
67
- const res = await ctx.client.session.messages({ path: { id: sessionId } })
68
- const msgs = res?.data || res || []
69
- if (!Array.isArray(msgs)) return { removed: 0, message: "no messages" }
70
-
71
- const selected = selectMessagesToReap(msgs, max, min, "compress", targetTokens)
72
- if (selected.length === 0) return { removed: 0, message: "already within target" }
73
-
74
- if (ss) {
75
- for (const r of selected) ss.prunedIds.add(r.id)
76
- ss.summary = summarizeRemoved(selected, summary)
77
- ss.anchorMessageId = selected[0].id
78
- }
79
-
80
- const tokensRemoved = selected.reduce((s, r) => s + r.tokens, 0)
81
- const beforeTotal = totalTokens(msgs)
82
- const afterTotal = beforeTotal - tokensRemoved
83
- if (ss) {
84
- ss.blockCount++
85
- ss.totalTokensSaved += tokensRemoved
86
- ss.totalMessagesRemoved += selected.length
87
- saveOhcState(sessionId, serializeState(ss))
88
- }
89
- return { removed: selected.length, afterTotal, tokensRemoved, beforeTotal, beforeCount: msgs.length, afterCount: msgs.length - selected.length }
90
- }
91
-
92
- function truncateText(s, n) {
93
- if (!s || s.length <= n) return s || ""
94
- return s.slice(0, n) + "..."
95
- }
96
-
97
- function estimateSummaryTokens(messages) {
98
- let total = 0
99
- for (const m of messages) {
100
- if (m.info?.role === "system" && Array.isArray(m.parts)) {
101
- for (const p of m.parts) {
102
- if (p.type === "text" && (p.text?.startsWith("[OHC: Compressed]") || p.text?.startsWith("[OHC: Auto-pruned]"))) {
103
- total += Math.ceil(p.text.length / 4)
104
- }
105
- }
106
- }
107
- }
108
- return total
109
- }
110
-
111
- async function executeRangeCompress(ctx, sessionId, callId, topic, content) {
112
- const ss = getOrCreateState(sessionId)
113
- if (!ss) throw new Error("No session state")
114
- ss.prunedIds.clear()
115
- ss.summary = null
116
- ss.anchorMessageId = null
117
- ss._pruneCycleDone = false
118
- ss.lastAutoPruneAt = null
119
-
120
- const res = await ctx.client.session.messages({ path: { id: sessionId } })
121
- const rawMessages = res?.data || res || []
122
- if (!Array.isArray(rawMessages) || rawMessages.length === 0) throw new Error("No messages")
123
-
124
- const searchContext = buildSearchContext(ss, rawMessages)
125
-
126
- const plans = content.map((entry, idx) => {
127
- const { startReference, endReference } = resolveBoundaryIds(searchContext, ss, entry.startId.trim(), entry.endId.trim())
128
- const selection = resolveSelection(searchContext, startReference, endReference)
129
- const anchorMessageId = resolveAnchorMessageId(startReference)
130
- return { index: idx, entry, selection, anchorMessageId }
131
- })
132
-
133
- validateNonOverlapping(plans)
134
-
135
- const runId = allocateRunId(ss)
136
- const notifications = []
137
- let totalActualTokensRemoved = 0
138
- const allMessageIds = []
139
-
140
- for (const plan of plans) {
141
- const blockId = allocateBlockId(ss)
142
- const storedSummary = wrapBlockSummary(blockId, plan.entry.summary)
143
- const summaryTokens = Math.ceil(storedSummary.length / 4)
144
-
145
- const actualTokensRemoved = plan.selection.messageIds.reduce((sum, mid) => {
146
- const msg = searchContext.rawMessagesById.get(mid)
147
- return msg ? sum + msgTokens(msg) : sum
148
- }, 0)
149
- totalActualTokensRemoved += actualTokensRemoved
150
- allMessageIds.push(...plan.selection.messageIds)
151
-
152
- applyCompressionState(
153
- ss,
154
- {
155
- topic,
156
- batchTopic: topic,
157
- startId: plan.entry.startId,
158
- endId: plan.entry.endId,
159
- mode: "range",
160
- runId,
161
- compressMessageId: plan.selection.messageIds[0],
162
- compressCallId: callId,
163
- summaryTokens,
164
- compressedTokens: actualTokensRemoved,
165
- },
166
- plan.selection,
167
- plan.anchorMessageId,
168
- blockId,
169
- storedSummary,
170
- plan.selection.requiredBlockIds,
171
- )
172
-
173
- ss.blockCount++
174
- ss.totalTokensSaved += actualTokensRemoved
175
- ss.totalMessagesRemoved += plan.selection.messageIds.length
176
-
177
- notifications.push({
178
- blockId,
179
- runId,
180
- summary: plan.entry.summary,
181
- messageIds: plan.selection.messageIds,
182
- })
183
- }
184
-
185
- saveOhcState(sessionId, serializeState(ss))
186
-
187
- return {
188
- messageIds: allMessageIds,
189
- compressedTokens: totalActualTokensRemoved,
190
- summaryRef: content[0]?.summary || topic,
191
- blockCount: plans.length,
192
- afterCount: rawMessages.length - allMessageIds.length,
193
- }
194
- }
195
-
196
- export const OhcPlugin = async (ctx) => {
197
- const config = loadConfig(ctx)
198
- if (!config.enabled) return {}
199
-
200
- const max = config.max
201
- const min = config.min
202
- if (max <= min + 10000) return {}
203
-
204
- let systemInjected = false
205
-
206
- function buildTimingStr(ss) {
207
- if (!ss?.compressionTiming?.totalDurationMs) return ""
208
- const last = ss.compressionTiming.lastDurationMs || 0
209
- const total = ss.compressionTiming.totalDurationMs || 0
210
- const lastSec = (last / 1000).toFixed(1)
211
- const totalSec = (total / 1000).toFixed(1)
212
- return ` (last: ${lastSec}s, total: ${totalSec}s)`
213
- }
214
-
215
- function computeRoleBreakdown(messages) {
216
- const roles = {}
217
- for (const m of messages) {
218
- const role = m.info?.role || "unknown"
219
- if (!roles[role]) roles[role] = { count: 0, tokens: 0 }
220
- roles[role].count++
221
- if (Array.isArray(m.parts)) {
222
- for (const p of m.parts) {
223
- if (p.type === "text") roles[role].tokens += Math.ceil((p.text || "").length / 4)
224
- else if (p.type === "tool") {
225
- if (p.state?.input) roles[role].tokens += JSON.stringify(p.state.input).length / 4
226
- if (p.state?.output)
227
- roles[role].tokens += (typeof p.state.output === "string" ? p.state.output : JSON.stringify(p.state.output ?? "")).length / 4
228
- }
229
- }
230
- }
231
- }
232
- return roles
233
- }
234
-
235
- function countIterationsSinceLastUser(messages) {
236
- let lastUserIdx = -1
237
- for (let i = messages.length - 1; i >= 0; i--) {
238
- if (messages[i].info?.role === "user") {
239
- lastUserIdx = i
240
- break
241
- }
242
- }
243
- if (lastUserIdx === -1) return 0
244
- return messages.length - 1 - lastUserIdx
245
- }
246
-
247
- return {
248
- "experimental.chat.system.transform": async (_input, output) => {
249
- if (systemInjected || !output.system?.length) return
250
- systemInjected = true
251
- output.system[output.system.length - 1] += `\n\n## Context Management\nOpenHermes manages context compression automatically. Set \`compaction.auto: false\` in opencode.json to disable.\n\n### Compress Tool\nUse \`compress\` to free context space. Two modes:\n- **Summary mode**: \`{ summary }\` — compresses oldest messages first\n- **Range mode**: \`{ topic, content: [{startId, endId, summary}] }\` — targets specific conversation ranges\n - \`startId\` / \`endId\`: \`ohcNNNN\` (message ref) or \`bkNN\` (block ref)\n - Ranges must not overlap within one call`
252
- },
253
-
254
- "experimental.chat.messages.transform": async (_input, output) => {
255
- if (!output?.messages?.length) return
256
-
257
- const sessionId = getSessionId(output.messages)
258
- if (!sessionId) return
259
-
260
- const ss = getOrCreateState(sessionId)
261
- if (!ss) return
262
-
263
- syncToolCache(ss, output.messages)
264
- buildToolIdList(ss, output.messages)
265
- ss.currentTurn = countTurns(ss, output.messages)
266
-
267
- assignMessageRefs(ss, output.messages)
268
- injectMessageIds(ss, output.messages)
269
- syncCompressionBlocks(ss, output.messages)
270
- filterCompressedBlocks(ss, output.messages)
271
-
272
- deduplicate(ss, config, output.messages)
273
- purgeErrors(ss, config, output.messages)
274
- applyPruneTools(ss, output.messages)
275
- applyFullToolRemoval(ss, output.messages)
276
-
277
- const now = Date.now()
278
- const recentlyPruned = ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) < AUTOPRUNE_COOLDOWN
279
-
280
- if (ss.prunedIds.size > 0 && !recentlyPruned) {
281
- const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
282
- if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
283
- cleanupMessageRefs(ss, ss.prunedIds)
284
- ss.prunedIds.clear()
285
- ss.summary = null
286
- ss.anchorMessageId = null
287
- ss._pruneCycleDone = false
288
- saveOhcState(sessionId, serializeState(ss))
289
- }
290
- }
291
-
292
- const currentTotal = totalTokens(output.messages)
293
- if (currentTotal > max && !recentlyPruned && !ss._pruneCycleDone) {
294
- const selected = selectMessagesToReap(output.messages, max, min)
295
- if (selected.length > 0) {
296
- for (const r of selected) ss.prunedIds.add(r.id)
297
- if (!ss.summary) ss.summary = summarizeRemoved(selected, null)
298
- if (!ss.anchorMessageId) ss.anchorMessageId = selected[0].id
299
- const tokensRemoved = selected.reduce((s, r) => s + r.tokens, 0)
300
- ss.blockCount++
301
- ss.totalTokensSaved += tokensRemoved
302
- ss.totalMessagesRemoved += selected.length
303
- saveOhcState(sessionId, serializeState(ss))
304
- ss.lastAutoPruneAt = now
305
- ss._pruneCycleDone = true
306
- }
307
- }
308
-
309
- if (ss._pruneCycleDone && ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) > AUTOPRUNE_COOLDOWN) {
310
- ss._pruneCycleDone = false
311
- }
312
-
313
- if (ss.prunedIds.size > 0) {
314
- const prunedIds = ss.prunedIds
315
- const summary = ss.summary
316
- const anchorId = ss.anchorMessageId
317
- const result = []
318
- let injected = false
319
-
320
- for (const msg of output.messages) {
321
- const msgId = msg.info?.id
322
-
323
- if (anchorId && msgId === anchorId && !injected && summary) {
324
- result.push(createSummaryMessage(summary))
325
- injected = true
326
- }
327
-
328
- if (msgId !== undefined && prunedIds.has(msgId)) continue
329
-
330
- result.push(msg)
331
- }
332
-
333
- if (!injected && summary && result.length > 1) {
334
- result.splice(1, 0, createSummaryMessage(summary))
335
- }
336
-
337
- output.messages.length = 0
338
- output.messages.push(...result)
339
- }
340
-
341
- const afterTotal = totalTokens(output.messages)
342
-
343
- const summaryBufferTotal = config.compress?.summaryBuffer
344
- ? estimateSummaryTokens(output.messages)
345
- : 0
346
- const effectiveMax = max + summaryBufferTotal
347
- const pct = afterTotal / effectiveMax
348
- const nudge = buildNudge(pct, effectiveMax)
349
-
350
- let nudgeText = null
351
- if (nudge && pct > ss.lastNudgePct + 0.05) {
352
- nudgeText = nudge
353
- ss.lastNudgePct = pct
354
- }
355
-
356
- const iterationThreshold = config.compress?.iterationNudgeThreshold ?? 15
357
- const iterCount = countIterationsSinceLastUser(output.messages)
358
- if (iterCount >= iterationThreshold && iterCount % 5 === 0) {
359
- const iterNudge = `OHC: ${iterCount} AI turns since your last input. Summarize with \`compress\`.`
360
- if (nudgeText) nudgeText += "\n\n" + iterNudge
361
- else nudgeText = iterNudge
362
- }
363
-
364
- const blockRefs = [...(ss.prune?.messages?.activeBlockIds || [])]
365
- .filter(id => Number.isInteger(id) && id > 0)
366
- .sort((a, b) => a - b)
367
- .map(id => `bk${id}`)
368
- let blockGuidance = null
369
- if (blockRefs.length > 0) {
370
- blockGuidance = `<ohc-reminder>\nActive compressed blocks: ${blockRefs.join(", ")}\nUse \`bkNN\` IDs as boundaries when compressing ranges that include previously compressed blocks.\n</ohc-reminder>`
371
- }
372
-
373
- if (nudgeText || blockGuidance) {
374
- const appendText = [nudgeText, blockGuidance].filter(Boolean).join("\n\n")
375
- for (let i = output.messages.length - 1; i >= 0; i--) {
376
- const m = output.messages[i]
377
- if (m.info?.role === "assistant" && m.parts?.length) {
378
- const textPart = m.parts.find(p => p.type === "text")
379
- if (textPart) { textPart.text += "\n\n" + appendText; break }
380
- }
381
- }
382
- }
383
- },
384
-
385
- event: async (input) => {
386
- if (input.event?.type !== "message.part.updated") return
387
- const part = input.event.properties?.part
388
- if (part?.type !== "tool" || part.tool !== "compress") return
389
-
390
- const sessionId = input.event.properties?.sessionID
391
- if (!sessionId) return
392
- const ss = getOrCreateState(sessionId)
393
- if (!ss) return
394
-
395
- if (part.state?.status === "pending") {
396
- if (typeof part.callID !== "string") return
397
- ss.compressionTiming.starts.set(part.callID, Date.now())
398
- return
399
- }
400
-
401
- if (part.state?.status === "completed") {
402
- if (typeof part.callID !== "string") return
403
- const start = ss.compressionTiming.starts.get(part.callID)
404
- if (!start) return
405
- ss.compressionTiming.starts.delete(part.callID)
406
- const durationMs = Date.now() - start
407
- ss.compressionTiming.lastDurationMs = durationMs
408
- ss.compressionTiming.totalDurationMs = (ss.compressionTiming.totalDurationMs || 0) + durationMs
409
- saveOhcState(sessionId, serializeState(ss))
410
- }
411
- },
412
-
413
- "command.execute.before": async (input, output) => {
414
- if (input.command !== "ohc") return
415
- const sub = (input.arguments || "").trim().toLowerCase()
416
- const args = (input.arguments || "").trim()
417
-
418
- if (sub === "status") {
419
- let msgs = [], t = 0
420
- try {
421
- if (ctx?.client?.session?.messages) {
422
- const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
423
- msgs = res?.data || res || []
424
- t = totalTokens(msgs)
425
- }
426
- } catch {}
427
- const ss = getOrCreateState(input.sessionID)
428
- const prunedCount = ss?.prunedIds.size || 0
429
- const strategyPruned = ss?.prune?.tools?.size || 0
430
- const timing = buildTimingStr(ss)
431
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
432
- const blockLine = activeBlockIds.length ? ` bk${activeBlockIds.join(", bk")}.` : ""
433
- const summaryBufferTotal = config.compress?.summaryBuffer
434
- ? estimateSummaryTokens(msgs)
435
- : 0
436
- const effectiveMax = max + summaryBufferTotal
437
- const text = `OHC: ${msgs.length} visible (${prunedCount} auto, ${strategyPruned} strategy) \u00b7 ~${Math.round(t / 1000)}K tok (${Math.round((t / effectiveMax) * 100)}%)${blockLine}${timing}`
438
- await ctx.client.session.prompt({
439
- path: { id: input.sessionID },
440
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
441
- })
442
- throw new Error("__OHC_STATUS_HANDLED__")
443
- }
444
-
445
- if (sub === "stats") {
446
- const ss = getOrCreateState(input.sessionID)
447
- const totalSaved = ss?.totalTokensSaved || 0
448
- const blocks = ss?.blockCount || 0
449
- const dedupPruned = ss?.prune?.tools?.size || 0
450
- const autoPruned = ss?.prunedIds?.size || 0
451
- const timing = buildTimingStr(ss)
452
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
453
- const blockLine = activeBlockIds.length ? ` \u00b7 bk${activeBlockIds.join(", bk")}` : ""
454
- const text = `OHC: ${blocks} blocks${blockLine} \u00b7 ~${Math.round(totalSaved / 1000)}K saved \u00b7 auto: ${autoPruned}, strategy: ${dedupPruned}${timing}`
455
- await ctx.client.session.prompt({
456
- path: { id: input.sessionID },
457
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
458
- })
459
- throw new Error("__OHC_STATS_HANDLED__")
460
- }
461
-
462
- if (sub === "manual") {
463
- const onOff = args.replace(/^manual\s*/i, "").trim().toLowerCase()
464
- if (onOff === "on" || onOff === "1" || onOff === "true") {
465
- const ss = getOrCreateState(input.sessionID)
466
- if (ss) ss.manualMode = "active"
467
- await ctx.client.session.prompt({
468
- path: { id: input.sessionID },
469
- body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: active \u2014 agent will not autonomously compress", ignored: true }] },
470
- })
471
- } else {
472
- const ss = getOrCreateState(input.sessionID)
473
- if (ss) ss.manualMode = false
474
- await ctx.client.session.prompt({
475
- path: { id: input.sessionID },
476
- body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: off \u2014 agent can compress autonomously", ignored: true }] },
477
- })
478
- }
479
- throw new Error("__OHC_MANUAL_HANDLED__")
480
- }
481
-
482
- if (sub.startsWith("compress")) {
483
- const rest = args.replace(/^compress\s*/i, "").trim()
484
- const numMatch = rest.match(/^(\d+)\s*(.*)/)
485
- let targetTokens
486
- let focus
487
- if (numMatch) {
488
- targetTokens = parseInt(numMatch[1], 10)
489
- focus = numMatch[2].trim() || "Manual compression"
490
- } else {
491
- focus = rest || "Manual compression"
492
- }
493
- try {
494
- const result = await applyCompress(ctx, input.sessionID, focus, max, min, targetTokens)
495
- const cmdSs = getOrCreateState(input.sessionID)
496
- await sendCompressNotification(ctx.client, input.sessionID, config, result.removed, focus, result.tokensRemoved, cmdSs, result.afterCount)
497
- output.parts.length = 0
498
- output.parts.push({
499
- type: "text",
500
- text: `OHC: Compressed ${result.removed} msgs. Summary: ${focus}`,
501
- })
502
- } catch {
503
- output.parts.length = 0
504
- output.parts.push({ type: "text", text: `/ohc compress ${focus}` })
505
- }
506
- return
507
- }
508
-
509
- if (sub === "context") {
510
- let msgs = [], t = 0
511
- try {
512
- if (ctx?.client?.session?.messages) {
513
- const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
514
- msgs = res?.data || res || []
515
- t = totalTokens(msgs)
516
- }
517
- } catch {}
518
- const ss = getOrCreateState(input.sessionID)
519
- const autoPruned = ss?.prunedIds?.size || 0
520
- const stratPruned = ss?.prune?.tools?.size || 0
521
- const totalSaved = ss?.totalTokensSaved || 0
522
- const blocks = ss?.blockCount || 0
523
- const visibleTokens = t
524
- const totalTokensWithPruned = visibleTokens + totalSaved
525
-
526
- const roles = computeRoleBreakdown(msgs)
527
- const roleLines = []
528
- for (const [role, info] of Object.entries(roles)) {
529
- const pct = t > 0 ? Math.round((info.tokens / t) * 100) : 0
530
- roleLines.push(`${role}: ${info.count} msgs, ~${Math.round(info.tokens / 1000)}K (${pct}%)`)
531
- }
532
-
533
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
534
- const blockLine = activeBlockIds.length ? ` \u00b7 bk${activeBlockIds.join(", bk")}` : ""
535
- const text = `OHC: ${msgs.length} visible (${autoPruned + stratPruned} pruned) \u00b7 ~${Math.round(visibleTokens / 1000)}K/~${Math.round(totalTokensWithPruned / 1000)}K \u00b7 ${blocks} blocks, ~${Math.round(totalSaved / 1000)}K saved${blockLine}\n\nRole breakdown:\n${roleLines.map(r => ` ${r}`).join("\n")}`
536
- await ctx.client.session.prompt({
537
- path: { id: input.sessionID },
538
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
539
- })
540
- throw new Error("__OHC_CONTEXT_HANDLED__")
541
- }
542
-
543
- if (sub === "sweep") {
544
- const rest = args.replace(/^sweep\s*/i, "").trim()
545
- let count = rest ? parseInt(rest, 10) : 10
546
- if (isNaN(count) || count < 1) count = 10
547
- const ss = getOrCreateState(input.sessionID)
548
- const allToolIds = ss?.toolIdList || []
549
- const unpruned = allToolIds.filter(id => !ss.prune.tools.has(id))
550
- const toSweep = unpruned.slice(-count)
551
- let sweptCount = 0
552
- for (const id of toSweep) {
553
- const entry = ss.toolParameters.get(id)
554
- if (entry) {
555
- ss.prune.tools.set(id, entry.tokenCount || 0)
556
- sweptCount++
557
- }
558
- }
559
- applyPruneTools(ss, output.messages)
560
- const text = `OHC Sweep: ${sweptCount} tool call${sweptCount === 1 ? "" : "s"} pruned`
561
- output.parts.length = 0
562
- output.parts.push({ type: "text", text })
563
- return
564
- }
565
-
566
- const text = "OHC: /ohc status | stats | context | sweep [n] | manual [on|off] | compress [focus]"
567
- await ctx.client.session.prompt({
568
- path: { id: input.sessionID },
569
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
570
- })
571
- throw new Error("__OHC_HELP_HANDLED__")
572
- },
573
-
574
- tool: {
575
- compress: tool({
576
- description: "Compress conversation content to free context space. Two modes: range mode (specify content array with startId/endId/summary per entry) and summary mode (specify summary with optional targetTokens). Use range for precise targeting; fall back to summary for general oldest-first pruning. In range mode, each startId/endId pair uses message or block references found in conversation. Each entry's summary replaces the entire range. Provide a technical summary of what was removed, including file paths, function signatures, decisions, and constraints.",
577
- args: {
578
- topic: tool.schema.string().optional().describe("Range mode: Short label (3-5 words) for the overall batch — e.g. 'Auth System Exploration'"),
579
- content: tool.schema.array(tool.schema.object({
580
- startId: tool.schema.string().describe("Boundary at range start: ohcNNNN (message) or bkNN (block)"),
581
- endId: tool.schema.string().describe("Boundary at range end: ohcNNNN (message) or bkNN (block)"),
582
- summary: tool.schema.string().describe("Complete technical summary replacing all content in this range. Include user intent, decisions, constraints, file paths, and function signatures."),
583
- })).optional().describe("Range mode: One or more non-overlapping ranges to compress"),
584
- summary: tool.schema.string().optional().describe("Legacy mode: Technical summary of compressed content. Use when not specifying content array."),
585
- targetTokens: tool.schema.number().optional().describe("Legacy mode: Estimated target after compression. Lower = more aggressive. Default uses soft config floor."),
586
- },
587
- async execute(args, toolCtx) {
588
- const callId = toolCtx.callID || null
589
- const sessionId = toolCtx.sessionID
590
-
591
- if (Array.isArray(args.content) && args.content.length > 0) {
592
- const result = await executeRangeCompress(ctx, sessionId, callId, args.topic || "Compression", args.content)
593
- toolCtx.metadata({ title: "Compress Range" })
594
- const resultSs = getOrCreateState(sessionId)
595
- await sendCompressNotification(ctx.client, sessionId, config, result.messageIds.length, result.summaryRef, result.compressedTokens, resultSs, result.afterCount || 0)
596
- return `OHC: Compressed ${result.messageIds.length} msgs across ${args.content.length} range(s). Summary: "${truncateText(result.summaryRef, 200)}"`
597
- }
598
-
599
- const result = await applyCompress(ctx, sessionId, args.summary, max, min, args.targetTokens)
600
- toolCtx.metadata({ title: "Compress" })
601
- const toolSs = getOrCreateState(sessionId)
602
- await sendCompressNotification(ctx.client, sessionId, config, result.removed, truncateText(args.summary, 200), result.tokensRemoved, toolSs, result.afterCount)
603
- return `OHC: Compressed ${result.removed} msgs. Summary: "${truncateText(args.summary, 200)}"`
604
- },
605
- }),
606
- },
607
- }
608
- }
@@ -1,70 +0,0 @@
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
- try { return Math.ceil(JSON.stringify(part).length / 4) } catch { return 0 }
11
- }
12
-
13
- export 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
- * turnProtectionTags: optional set of message IDs to protect from pruning.
33
- * These are recent tool call message IDs that should be kept.
34
- */
35
- export function selectMessagesToReap(messages, maxLimit, minFloor, mode = "auto", targetOverride, turnProtectionTags) {
36
- if (!messages?.length || messages.length < 3) return []
37
-
38
- const protectSet = turnProtectionTags?.length ? new Set(turnProtectionTags) : null
39
-
40
- let total = totalTokens(messages)
41
- const selected = []
42
-
43
- if (mode === "compress") {
44
- const floor = targetOverride ?? minFloor
45
- const tokenCache = messages.map((msg, i) => ({ idx: i, tokens: msgTokens(msg) }))
46
- let i = 1
47
- while (i < messages.length - 1) {
48
- if (protectSet?.has(String(messages[i].info?.id ?? i))) { i++; continue }
49
- const t = tokenCache[i].tokens
50
- if (total - t < floor) break
51
- total -= t
52
- selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
53
- i++
54
- }
55
- } else {
56
- const floor = targetOverride ?? minFloor
57
- const tokenCache = messages.map((msg, i) => ({ idx: i, tokens: msgTokens(msg) }))
58
- let i = 1
59
- while (i < messages.length - 1 && total > maxLimit) {
60
- if (protectSet?.has(String(messages[i].info?.id ?? i))) { i++; continue }
61
- const t = tokenCache[i].tokens
62
- if (total - t < floor) break
63
- total -= t
64
- selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
65
- i++
66
- }
67
- }
68
-
69
- return selected
70
- }