openhermes 2.6.1 → 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 -499
  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 -58
  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 -455
  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 -176
  128. package/lib/hardening.mjs +0 -128
  129. package/lib/memory-tools-plugin.mjs +0 -365
  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 -186
  134. package/lib/ohc/message-ids.mjs +0 -168
  135. package/lib/ohc/notify.mjs +0 -154
  136. package/lib/ohc/protected-patterns.mjs +0 -54
  137. package/lib/ohc/prune-apply.mjs +0 -134
  138. package/lib/ohc/pruner.mjs +0 -610
  139. package/lib/ohc/reaper.mjs +0 -70
  140. package/lib/ohc/state.mjs +0 -266
  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 -133
  146. package/lib/paths.mjs +0 -50
  147. package/lib/schema-validator.mjs +0 -77
  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,610 +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 } from "./prune-apply.mjs"
12
- import { assignMessageRefs, injectMessageIds } 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 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.`
42
- if (pct > 0.85) return `[OHC] Context at ${Math.round(pct * 100)}%. Proactive compression recommended. Run \`compress\` to free space.`
43
- if (pct > 0.70) return `[OHC] Context at ${Math.round(pct * 100)}% of budget. Consider using \`compress\` to keep room for new content.`
44
- return null
45
- }
46
-
47
- function summarizeRemoved(selected, summary) {
48
- const n = selected.length
49
- if (summary) return `[Compressed: ${summary} — ${n} message${n === 1 ? "" : "s"} removed]`
50
- return `[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("[Compressed:") || p.text?.startsWith("[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 compress ${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
- const summaryBufNote = config.compress?.summaryBuffer ? ` Summary messages extend the budget.` : ``
252
- const protectedToolsList = (config.compress?.protectedTools || []).length
253
- ? ` Protected tools (${(config.compress.protectedTools).join(", ")}) are preserved.`
254
- : ``
255
- output.system[output.system.length - 1] += `\n\n## OHC Context Management\n- OHC manages all compression. Set \`compaction.auto: false\` in opencode.json.\n- Default budget: ${max.toLocaleString()} tokens. Floor: ${min.toLocaleString()}.${summaryBufNote}${protectedToolsList}\n\n### Compress Tool\nUse \`compress\` to free context space. Two modes:\n- **Legacy**: \`{ summary }\` — oldest messages first\n- **Range**: \`{ topic, content: [{startId, endId, summary}] }\` — target specific ranges\n - \`startId\` / \`endId\`: \`ohcNNNN\` (message) or \`bkNN\` (block)\n - Each message in context has an \`<ohc-ref>\` tag with its ID\n - Ranges must be non-overlapping in one call\n - Summary replaces the entire range`
256
- },
257
-
258
- "experimental.chat.messages.transform": async (_input, output) => {
259
- if (!output?.messages?.length) return
260
-
261
- const sessionId = getSessionId(output.messages)
262
- if (!sessionId) return
263
-
264
- const ss = getOrCreateState(sessionId)
265
- if (!ss) return
266
-
267
- syncToolCache(ss, output.messages)
268
- buildToolIdList(ss, output.messages)
269
- ss.currentTurn = countTurns(ss, output.messages)
270
-
271
- assignMessageRefs(ss, output.messages)
272
- injectMessageIds(ss, output.messages)
273
- syncCompressionBlocks(ss, output.messages)
274
- filterCompressedBlocks(ss, output.messages)
275
-
276
- deduplicate(ss, config, output.messages)
277
- purgeErrors(ss, config, output.messages)
278
- applyPruneTools(ss, output.messages)
279
-
280
- const now = Date.now()
281
- const recentlyPruned = ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) < AUTOPRUNE_COOLDOWN
282
-
283
- if (ss.prunedIds.size > 0 && !recentlyPruned) {
284
- const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
285
- if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
286
- ss.prunedIds.clear()
287
- ss.summary = null
288
- ss.anchorMessageId = null
289
- ss._pruneCycleDone = false
290
- saveOhcState(sessionId, serializeState(ss))
291
- }
292
- }
293
-
294
- const currentTotal = totalTokens(output.messages)
295
- if (currentTotal > max && !recentlyPruned && !ss._pruneCycleDone) {
296
- const selected = selectMessagesToReap(output.messages, max, min)
297
- if (selected.length > 0) {
298
- for (const r of selected) ss.prunedIds.add(r.id)
299
- if (!ss.summary) ss.summary = summarizeRemoved(selected, null)
300
- if (!ss.anchorMessageId) ss.anchorMessageId = selected[0].id
301
- const tokensRemoved = selected.reduce((s, r) => s + r.tokens, 0)
302
- ss.blockCount++
303
- ss.totalTokensSaved += tokensRemoved
304
- ss.totalMessagesRemoved += selected.length
305
- saveOhcState(sessionId, serializeState(ss))
306
- ss.lastAutoPruneAt = now
307
- ss._pruneCycleDone = true
308
- }
309
- }
310
-
311
- if (ss._pruneCycleDone && ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) > AUTOPRUNE_COOLDOWN) {
312
- ss._pruneCycleDone = false
313
- }
314
-
315
- if (ss.prunedIds.size > 0) {
316
- const prunedIds = ss.prunedIds
317
- const summary = ss.summary
318
- const anchorId = ss.anchorMessageId
319
- const result = []
320
- let injected = false
321
-
322
- for (const msg of output.messages) {
323
- const msgId = msg.info?.id
324
-
325
- if (anchorId && msgId === anchorId && !injected && summary) {
326
- result.push(createSummaryMessage(summary))
327
- injected = true
328
- }
329
-
330
- if (msgId !== undefined && prunedIds.has(msgId)) continue
331
-
332
- result.push(msg)
333
- }
334
-
335
- if (!injected && summary && result.length > 1) {
336
- result.splice(1, 0, createSummaryMessage(summary))
337
- }
338
-
339
- output.messages.length = 0
340
- output.messages.push(...result)
341
- }
342
-
343
- const afterTotal = totalTokens(output.messages)
344
-
345
- const summaryBufferTotal = config.compress?.summaryBuffer
346
- ? estimateSummaryTokens(output.messages)
347
- : 0
348
- const effectiveMax = max + summaryBufferTotal
349
- const pct = afterTotal / effectiveMax
350
- const nudge = buildNudge(pct, effectiveMax)
351
-
352
- let nudgeText = null
353
- if (nudge && pct > ss.lastNudgePct + 0.05) {
354
- nudgeText = nudge
355
- ss.lastNudgePct = pct
356
- }
357
-
358
- const iterationThreshold = config.compress?.iterationNudgeThreshold ?? 15
359
- const iterCount = countIterationsSinceLastUser(output.messages)
360
- if (iterCount >= iterationThreshold && iterCount % 5 === 0) {
361
- const iterNudge = `[OHC] ${iterCount} AI iterations since your last message. Consider summarizing completed work with \`compress\`.`
362
- if (nudgeText) nudgeText += "\n\n" + iterNudge
363
- else nudgeText = iterNudge
364
- }
365
-
366
- const blockRefs = [...(ss.prune?.messages?.activeBlockIds || [])]
367
- .filter(id => Number.isInteger(id) && id > 0)
368
- .sort((a, b) => a - b)
369
- .map(id => `bk${id}`)
370
- let blockGuidance = null
371
- if (blockRefs.length > 0) {
372
- blockGuidance = `<ohc-reminder>\nActive compressed blocks: ${blockRefs.join(", ")}\nUse \`bkNN\` IDs as boundaries when compressing ranges that include previously compressed blocks.\n</ohc-reminder>`
373
- }
374
-
375
- if (nudgeText || blockGuidance) {
376
- const appendText = [nudgeText, blockGuidance].filter(Boolean).join("\n\n")
377
- for (let i = output.messages.length - 1; i >= 0; i--) {
378
- const m = output.messages[i]
379
- if (m.info?.role === "assistant" && m.parts?.length) {
380
- const textPart = m.parts.find(p => p.type === "text")
381
- if (textPart) { textPart.text += "\n\n" + appendText; break }
382
- }
383
- }
384
- }
385
- },
386
-
387
- event: async (input) => {
388
- if (input.event?.type !== "message.part.updated") return
389
- const part = input.event.properties?.part
390
- if (part?.type !== "tool" || part.tool !== "compress") return
391
-
392
- const sessionId = input.event.properties?.sessionID
393
- if (!sessionId) return
394
- const ss = getOrCreateState(sessionId)
395
- if (!ss) return
396
-
397
- if (part.state?.status === "pending") {
398
- if (typeof part.callID !== "string") return
399
- ss.compressionTiming.starts.set(part.callID, Date.now())
400
- return
401
- }
402
-
403
- if (part.state?.status === "completed") {
404
- if (typeof part.callID !== "string") return
405
- const start = ss.compressionTiming.starts.get(part.callID)
406
- if (!start) return
407
- ss.compressionTiming.starts.delete(part.callID)
408
- const durationMs = Date.now() - start
409
- ss.compressionTiming.lastDurationMs = durationMs
410
- ss.compressionTiming.totalDurationMs = (ss.compressionTiming.totalDurationMs || 0) + durationMs
411
- saveOhcState(sessionId, serializeState(ss))
412
- }
413
- },
414
-
415
- "command.execute.before": async (input, output) => {
416
- if (input.command !== "ohc") return
417
- const sub = (input.arguments || "").trim().toLowerCase()
418
- const args = (input.arguments || "").trim()
419
-
420
- if (sub === "status") {
421
- let msgs = [], t = 0
422
- try {
423
- if (ctx?.client?.session?.messages) {
424
- const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
425
- msgs = res?.data || res || []
426
- t = totalTokens(msgs)
427
- }
428
- } catch {}
429
- const ss = getOrCreateState(input.sessionID)
430
- const prunedCount = ss?.prunedIds.size || 0
431
- const strategyPruned = ss?.prune?.tools?.size || 0
432
- const timing = buildTimingStr(ss)
433
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
434
- const blockLine = activeBlockIds.length ? ` Blocks: bk${activeBlockIds.join(", bk")}.` : ""
435
- const summaryBufferTotal = config.compress?.summaryBuffer
436
- ? estimateSummaryTokens(msgs)
437
- : 0
438
- const effectiveMax = max + summaryBufferTotal
439
- const text = `[OHC Status] ${msgs.length} messages visible (${prunedCount} auto-pruned, ${strategyPruned} strategy-pruned)${blockLine}${timing}. ~${Math.round(t / 1000)}K / ${effectiveMax.toLocaleString()} tokens (${Math.round((t / effectiveMax) * 100)}%). Soft floor: ${min.toLocaleString()}.`
440
- await ctx.client.session.prompt({
441
- path: { id: input.sessionID },
442
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
443
- })
444
- throw new Error("__OHC_STATUS_HANDLED__")
445
- }
446
-
447
- if (sub === "stats") {
448
- const ss = getOrCreateState(input.sessionID)
449
- const totalSaved = ss?.totalTokensSaved || 0
450
- const blocks = ss?.blockCount || 0
451
- const dedupPruned = ss?.prune?.tools?.size || 0
452
- const autoPruned = ss?.prunedIds?.size || 0
453
- const timing = buildTimingStr(ss)
454
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
455
- const blockLine = activeBlockIds.length ? ` Active: bk${activeBlockIds.join(", bk")}.` : ""
456
- const text = `[OHC Stats] ${blocks} total compression blocks${blockLine} ~${Math.round(totalSaved / 1000)}K tokens saved${timing}. Auto-pruned: ${autoPruned} messages. Strategy-pruned: ${dedupPruned} calls.`
457
- await ctx.client.session.prompt({
458
- path: { id: input.sessionID },
459
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
460
- })
461
- throw new Error("__OHC_STATS_HANDLED__")
462
- }
463
-
464
- if (sub === "manual") {
465
- const onOff = args.replace(/^manual\s*/i, "").trim().toLowerCase()
466
- if (onOff === "on" || onOff === "1" || onOff === "true") {
467
- const ss = getOrCreateState(input.sessionID)
468
- if (ss) ss.manualMode = "active"
469
- await ctx.client.session.prompt({
470
- path: { id: input.sessionID },
471
- body: { noReply: true, parts: [{ type: "text", text: "[OHC] Manual mode enabled. Agent will not autonomously compress.", ignored: true }] },
472
- })
473
- } else {
474
- const ss = getOrCreateState(input.sessionID)
475
- if (ss) ss.manualMode = false
476
- await ctx.client.session.prompt({
477
- path: { id: input.sessionID },
478
- body: { noReply: true, parts: [{ type: "text", text: "[OHC] Manual mode disabled. Agent can compress autonomously.", ignored: true }] },
479
- })
480
- }
481
- throw new Error("__OHC_MANUAL_HANDLED__")
482
- }
483
-
484
- if (sub.startsWith("compress")) {
485
- const rest = args.replace(/^compress\s*/i, "").trim()
486
- const numMatch = rest.match(/^(\d+)\s*(.*)/)
487
- let targetTokens
488
- let focus
489
- if (numMatch) {
490
- targetTokens = parseInt(numMatch[1], 10)
491
- focus = numMatch[2].trim() || "Manual compression"
492
- } else {
493
- focus = rest || "Manual compression"
494
- }
495
- try {
496
- const result = await applyCompress(ctx, input.sessionID, focus, max, min, targetTokens)
497
- const cmdSs = getOrCreateState(input.sessionID)
498
- await sendCompressNotification(ctx.client, input.sessionID, config, result.removed, focus, result.tokensRemoved, cmdSs, result.afterCount)
499
- output.parts.length = 0
500
- output.parts.push({
501
- type: "text",
502
- text: `[OHC] Compressed: ${result.removed} messages removed. Summary: ${focus}`,
503
- })
504
- } catch {
505
- output.parts.length = 0
506
- output.parts.push({ type: "text", text: `/ohc compress ${focus}` })
507
- }
508
- return
509
- }
510
-
511
- if (sub === "context") {
512
- let msgs = [], t = 0
513
- try {
514
- if (ctx?.client?.session?.messages) {
515
- const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
516
- msgs = res?.data || res || []
517
- t = totalTokens(msgs)
518
- }
519
- } catch {}
520
- const ss = getOrCreateState(input.sessionID)
521
- const autoPruned = ss?.prunedIds?.size || 0
522
- const stratPruned = ss?.prune?.tools?.size || 0
523
- const totalSaved = ss?.totalTokensSaved || 0
524
- const blocks = ss?.blockCount || 0
525
- const visibleTokens = t
526
- const totalTokensWithPruned = visibleTokens + totalSaved
527
-
528
- const roles = computeRoleBreakdown(msgs)
529
- const roleLines = []
530
- for (const [role, info] of Object.entries(roles)) {
531
- const pct = t > 0 ? Math.round((info.tokens / t) * 100) : 0
532
- roleLines.push(`${role}: ${info.count} msgs, ~${Math.round(info.tokens / 1000)}K (${pct}%)`)
533
- }
534
-
535
- const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
536
- const blockLine = activeBlockIds.length ? ` Active blocks: bk${activeBlockIds.join(", bk")}.` : ""
537
- const text = `[OHC Context] ${msgs.length} visible messages (${autoPruned + stratPruned} pruned). Tokens: ~${Math.round(visibleTokens / 1000)}K visible / ~${Math.round(totalTokensWithPruned / 1000)}K total. ${blocks} compression blocks, ~${Math.round(totalSaved / 1000)}K saved. ${blockLine}\n\nBreakdown:\n${roleLines.join("\n")}`
538
- await ctx.client.session.prompt({
539
- path: { id: input.sessionID },
540
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
541
- })
542
- throw new Error("__OHC_CONTEXT_HANDLED__")
543
- }
544
-
545
- if (sub === "sweep") {
546
- const rest = args.replace(/^sweep\s*/i, "").trim()
547
- let count = rest ? parseInt(rest, 10) : 10
548
- if (isNaN(count) || count < 1) count = 10
549
- const ss = getOrCreateState(input.sessionID)
550
- const allToolIds = ss?.toolIdList || []
551
- const unpruned = allToolIds.filter(id => !ss.prune.tools.has(id))
552
- const toSweep = unpruned.slice(-count)
553
- let sweptCount = 0
554
- for (const id of toSweep) {
555
- const entry = ss.toolParameters.get(id)
556
- if (entry) {
557
- ss.prune.tools.set(id, entry.tokenCount || 0)
558
- sweptCount++
559
- }
560
- }
561
- applyPruneTools(ss, output.messages)
562
- const text = `[OHC] Swept: ${sweptCount} tool calls pruned.`
563
- output.parts.length = 0
564
- output.parts.push({ type: "text", text })
565
- return
566
- }
567
-
568
- const text = "OHC commands: /ohc status — /ohc stats — /ohc context — /ohc sweep [n] — /ohc manual [on|off] — /ohc compress [focus]"
569
- await ctx.client.session.prompt({
570
- path: { id: input.sessionID },
571
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
572
- })
573
- throw new Error("__OHC_HELP_HANDLED__")
574
- },
575
-
576
- tool: {
577
- compress: tool({
578
- description: "Compress conversation content to free context space. Supports two modes: range mode (specify content array with startId/endId/summary per entry) and legacy mode (specify summary with optional targetTokens). Use range mode for precise targeting; fall back to legacy for general oldest-first pruning. When using range mode, wrap each boundary pair: startId (ohcNNNN or bkNN) must appear before endId. Each entry's summary replaces that conversation range entirely. Provide a technical summary of what was removed, including file paths, function signatures, decisions, and constraints.",
579
- args: {
580
- topic: tool.schema.string().optional().describe("Range mode: Short label (3-5 words) for the overall batch — e.g. 'Auth System Exploration'"),
581
- content: tool.schema.array(tool.schema.object({
582
- startId: tool.schema.string().describe("Boundary at range start: ohcNNNN (message) or bkNN (block)"),
583
- endId: tool.schema.string().describe("Boundary at range end: ohcNNNN (message) or bkNN (block)"),
584
- summary: tool.schema.string().describe("Complete technical summary replacing all content in this range. Include user intent, decisions, constraints, file paths, and function signatures."),
585
- })).optional().describe("Range mode: One or more non-overlapping ranges to compress"),
586
- summary: tool.schema.string().optional().describe("Legacy mode: Technical summary of compressed content. Use when not specifying content array."),
587
- targetTokens: tool.schema.number().optional().describe("Legacy mode: Estimated target after compression. Lower = more aggressive. Default uses soft config floor."),
588
- },
589
- async execute(args, toolCtx) {
590
- const callId = toolCtx.callID || null
591
- const sessionId = toolCtx.sessionID
592
-
593
- if (Array.isArray(args.content) && args.content.length > 0) {
594
- const result = await executeRangeCompress(ctx, sessionId, callId, args.topic || "Compression", args.content)
595
- toolCtx.metadata({ title: "Compress Range" })
596
- const resultSs = getOrCreateState(sessionId)
597
- await sendCompressNotification(ctx.client, sessionId, config, result.messageIds.length, result.summaryRef, result.compressedTokens, resultSs, result.afterCount || 0)
598
- return `Compressed ${result.messageIds.length} messages across ${args.content.length} range(s). Summary: "${truncateText(result.summaryRef, 200)}"`
599
- }
600
-
601
- const result = await applyCompress(ctx, sessionId, args.summary, max, min, args.targetTokens)
602
- toolCtx.metadata({ title: "Compress" })
603
- const toolSs = getOrCreateState(sessionId)
604
- await sendCompressNotification(ctx.client, sessionId, config, result.removed, truncateText(args.summary, 200), result.tokensRemoved, toolSs, result.afterCount)
605
- return `Compressed: ${result.removed} messages removed. Summary: "${truncateText(args.summary, 200)}"`
606
- },
607
- }),
608
- },
609
- }
610
- }
@@ -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
- return Math.ceil(JSON.stringify(part).length / 4)
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
- }