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.
- package/CONTEXT.md +18 -0
- package/ETHOS.md +15 -0
- package/README.md +135 -292
- package/bootstrap.mjs +174 -499
- package/harness/agents/openhermes.md +87 -0
- package/harness/codex/CONSTITUTION.md +70 -148
- package/harness/codex/ROUTING.md +126 -0
- package/harness/commands/oh-doctor.md +26 -0
- package/harness/instructions/CONVENTIONS.md +206 -206
- package/harness/instructions/RUNTIME.md +54 -31
- package/harness/skills/oh-builder/SKILL.md +98 -0
- package/harness/skills/oh-caveman/SKILL.md +33 -0
- package/harness/skills/oh-expert/SKILL.md +121 -0
- package/harness/skills/oh-freeze/SKILL.md +28 -0
- package/harness/skills/oh-gauntlet/SKILL.md +119 -0
- package/harness/skills/oh-grill/SKILL.md +77 -0
- package/harness/skills/oh-guard/SKILL.md +33 -0
- package/harness/skills/oh-handoff/SKILL.md +33 -0
- package/harness/skills/oh-health/SKILL.md +90 -0
- package/harness/skills/oh-init/SKILL.md +78 -0
- package/harness/skills/oh-investigate/SKILL.md +35 -0
- package/harness/skills/oh-issue/SKILL.md +36 -0
- package/harness/skills/oh-learn/SKILL.md +28 -0
- package/harness/skills/oh-manifest/SKILL.md +84 -0
- package/harness/skills/oh-plan-review/SKILL.md +128 -0
- package/harness/skills/oh-planner/SKILL.md +157 -0
- package/harness/skills/oh-prd/SKILL.md +35 -0
- package/harness/skills/oh-retro/SKILL.md +33 -0
- package/harness/skills/oh-review/SKILL.md +110 -0
- package/harness/skills/oh-security/SKILL.md +110 -0
- package/harness/skills/oh-ship/SKILL.md +39 -0
- package/harness/skills/oh-skill-craft/SKILL.md +107 -0
- package/harness/skills/oh-skills-link/SKILL.md +29 -0
- package/harness/skills/oh-skills-list/SKILL.md +31 -0
- package/harness/skills/oh-triage/SKILL.md +36 -0
- package/index.mjs +3 -58
- package/lib/harness-resolver.mjs +77 -0
- package/lib/logger.mjs +62 -0
- package/package.json +49 -53
- package/test/plugins-behavioral.test.mjs +64 -0
- package/test/plugins.test.mjs +62 -0
- package/autorecall.mjs +0 -237
- package/curator.mjs +0 -455
- package/harness/commands/build-fix.md +0 -60
- package/harness/commands/checkpoint.md +0 -68
- package/harness/commands/code-review.md +0 -71
- package/harness/commands/doctor.md +0 -42
- package/harness/commands/eval.md +0 -89
- package/harness/commands/go-build.md +0 -87
- package/harness/commands/go-review.md +0 -71
- package/harness/commands/harness-audit.md +0 -90
- package/harness/commands/learn.md +0 -37
- package/harness/commands/loop-start.md +0 -38
- package/harness/commands/loop-status.md +0 -30
- package/harness/commands/memory-search.md +0 -37
- package/harness/commands/model-route.md +0 -32
- package/harness/commands/ohc.md +0 -13
- package/harness/commands/orchestrate.md +0 -88
- package/harness/commands/plan.md +0 -53
- package/harness/commands/quality-gate.md +0 -35
- package/harness/commands/refactor-clean.md +0 -102
- package/harness/commands/rust-build.md +0 -78
- package/harness/commands/rust-review.md +0 -65
- package/harness/commands/security.md +0 -93
- package/harness/commands/setup-pm.md +0 -65
- package/harness/commands/skill-create.md +0 -99
- package/harness/commands/test-coverage.md +0 -80
- package/harness/commands/update-codemaps.md +0 -81
- package/harness/commands/update-docs.md +0 -67
- package/harness/commands/verify.md +0 -68
- package/harness/prompts/architect.txt +0 -189
- package/harness/prompts/build-cpp.md +0 -98
- package/harness/prompts/build-error-resolver.md +0 -44
- package/harness/prompts/build-go.md +0 -340
- package/harness/prompts/build-java.md +0 -140
- package/harness/prompts/build-kotlin.md +0 -137
- package/harness/prompts/build-rust.md +0 -108
- package/harness/prompts/code-reviewer.md +0 -40
- package/harness/prompts/doc-updater.md +0 -206
- package/harness/prompts/docs-lookup.md +0 -71
- package/harness/prompts/e2e-runner.txt +0 -317
- package/harness/prompts/explore.md +0 -42
- package/harness/prompts/harness-optimizer.md +0 -42
- package/harness/prompts/loop-operator.md +0 -53
- package/harness/prompts/planner.md +0 -37
- package/harness/prompts/refactor-cleaner.md +0 -256
- package/harness/prompts/review-cpp.md +0 -81
- package/harness/prompts/review-database.md +0 -261
- package/harness/prompts/review-go.md +0 -257
- package/harness/prompts/review-java.md +0 -113
- package/harness/prompts/review-kotlin.md +0 -143
- package/harness/prompts/review-python.md +0 -101
- package/harness/prompts/review-rust.md +0 -77
- package/harness/prompts/security-reviewer.md +0 -42
- package/harness/prompts/tdd-guide.md +0 -228
- package/harness/rules/audit.md +0 -84
- package/harness/rules/checkpointing.md +0 -75
- package/harness/rules/context-loading.md +0 -33
- package/harness/rules/credential-exposure.md +0 -0
- package/harness/rules/delegation.md +0 -80
- package/harness/rules/handoff.md +0 -267
- package/harness/rules/memory-management.md +0 -28
- package/harness/rules/precedence.md +0 -52
- package/harness/rules/promotion.md +0 -46
- package/harness/rules/ranking.md +0 -64
- package/harness/rules/retrieval.md +0 -94
- package/harness/rules/runtime-guards.md +0 -196
- package/harness/rules/self-heal.md +0 -79
- package/harness/rules/session-start.md +0 -34
- package/harness/rules/skills-management.md +0 -165
- package/harness/rules/state-drift.md +0 -192
- package/harness/rules/verification.md +0 -88
- package/harness/scripts/sync-commands.mjs +0 -259
- package/harness/skills/.bundled_manifest +0 -17
- package/harness/skills/.usage.json +0 -6
- package/harness/skills/api-design/SKILL.md +0 -523
- package/harness/skills/backend-patterns/SKILL.md +0 -598
- package/harness/skills/coding-standards/SKILL.md +0 -549
- package/harness/skills/e2e-testing/SKILL.md +0 -326
- package/harness/skills/frontend-patterns/SKILL.md +0 -642
- package/harness/skills/frontend-slides/SKILL.md +0 -184
- package/harness/skills/security-review/SKILL.md +0 -495
- package/harness/skills/strategic-compact/SKILL.md +0 -131
- package/harness/skills/tdd-workflow/SKILL.md +0 -463
- package/harness/skills/verification-loop/SKILL.md +0 -126
- package/lib/ambient-memory.mjs +0 -167
- package/lib/handoff.mjs +0 -176
- package/lib/hardening.mjs +0 -128
- package/lib/memory-tools-plugin.mjs +0 -365
- package/lib/ohc/block-sync.mjs +0 -69
- package/lib/ohc/compress/search.mjs +0 -152
- package/lib/ohc/compress/state.mjs +0 -76
- package/lib/ohc/config.mjs +0 -186
- package/lib/ohc/message-ids.mjs +0 -168
- package/lib/ohc/notify.mjs +0 -154
- package/lib/ohc/protected-patterns.mjs +0 -54
- package/lib/ohc/prune-apply.mjs +0 -134
- package/lib/ohc/pruner.mjs +0 -610
- package/lib/ohc/reaper.mjs +0 -70
- package/lib/ohc/state.mjs +0 -266
- package/lib/ohc/strategies/deduplication.mjs +0 -72
- package/lib/ohc/strategies/index.mjs +0 -2
- package/lib/ohc/strategies/purge-errors.mjs +0 -43
- package/lib/ohc/token-utils.mjs +0 -26
- package/lib/ohc/updater.mjs +0 -133
- package/lib/paths.mjs +0 -50
- package/lib/schema-validator.mjs +0 -77
- package/lib/search.mjs +0 -48
- package/schemas/audit.schema.json +0 -82
- package/schemas/backlog.schema.json +0 -63
- package/schemas/checkpoint.schema.json +0 -65
- package/schemas/constraint.schema.json +0 -62
- package/schemas/decision.schema.json +0 -63
- package/schemas/instinct.schema.json +0 -63
- package/schemas/loop-state.schema.json +0 -33
- package/schemas/mistake.schema.json +0 -64
- package/schemas/verification_receipt.schema.json +0 -88
- package/skill-builder.mjs +0 -88
package/lib/ohc/pruner.mjs
DELETED
|
@@ -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
|
-
}
|
package/lib/ohc/reaper.mjs
DELETED
|
@@ -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
|
-
}
|