openhermes 2.6.0 → 2.8.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/README.md +1 -12
- package/autorecall.mjs +1 -1
- package/bootstrap.mjs +45 -32
- package/curator.mjs +37 -10
- package/harness/rules/state-drift.md +1 -1
- package/index.mjs +3 -1
- package/lib/handoff.mjs +0 -5
- package/lib/hardening.mjs +21 -3
- package/lib/memory-tools-plugin.mjs +12 -9
- package/lib/ohc/compress/state.mjs +2 -2
- package/lib/ohc/config.mjs +3 -4
- package/lib/ohc/message-ids.mjs +10 -0
- package/lib/ohc/notify.mjs +35 -50
- package/lib/ohc/protected-patterns.mjs +3 -2
- package/lib/ohc/pruner.mjs +56 -38
- package/lib/ohc/reaper.mjs +2 -2
- package/lib/ohc/state.mjs +9 -7
- package/lib/ohc/updater.mjs +13 -14
- package/lib/paths.mjs +2 -3
- package/lib/schema-validator.mjs +7 -5
- package/lib/search.mjs +1 -1
- package/package.json +1 -1
- package/schemas/backlog.schema.json +1 -1
- package/schemas/instinct.schema.json +2 -2
- package/schemas/mistake.schema.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ No Python. No Docker. No cron. No database. Just Node.js and your existing OpenC
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
-
## Agent Handoff Protocol
|
|
39
|
+
## Agent Handoff Protocol
|
|
40
40
|
|
|
41
41
|
The reason most agent sessions descend into chaos: every agent thinks it can do everything. OpenHermes fixes that with a **structured handoff system** baked into every subagent prompt.
|
|
42
42
|
|
|
@@ -259,17 +259,6 @@ openhermes/
|
|
|
259
259
|
|
|
260
260
|
---
|
|
261
261
|
|
|
262
|
-
## Environment Variables
|
|
263
|
-
|
|
264
|
-
Two knobs. That's it.
|
|
265
|
-
|
|
266
|
-
| Variable | Default | Effect |
|
|
267
|
-
|----------|---------|--------|
|
|
268
|
-
| `OPENCODE_ALLOW_PROJECT_HARNESS` | `false` | Enable project-local harness at `.opencode/openhermes/` |
|
|
269
|
-
| `OPENCODE_CURATOR_LOGS` | `false` | Pipe curator diagnostics to stderr for debugging |
|
|
270
|
-
|
|
271
|
-
---
|
|
272
|
-
|
|
273
262
|
## Why OpenHermes ≠ Hermes Agent
|
|
274
263
|
|
|
275
264
|
Same messenger emoji. Entirely different mediums.
|
package/autorecall.mjs
CHANGED
|
@@ -186,7 +186,7 @@ async function loadMemoryAndWriteCache(projectKey, directory) {
|
|
|
186
186
|
const backlogIndex = readJson(path.join(getMemoryRoot(), "backlog", "index.json"), [])
|
|
187
187
|
if (Array.isArray(backlogIndex)) {
|
|
188
188
|
memory.pendingSkillCandidates = backlogIndex.filter(e =>
|
|
189
|
-
e.status === "open" && (e.
|
|
189
|
+
e.status === "open" && Array.isArray(e.tags) && e.tags.includes("skill-candidate")
|
|
190
190
|
)
|
|
191
191
|
}
|
|
192
192
|
|
package/bootstrap.mjs
CHANGED
|
@@ -67,11 +67,16 @@ export function resolveHarnessRoot({
|
|
|
67
67
|
return path.resolve(currentDir, "harness")
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
let _harnessDir
|
|
71
|
+
export function setHarnessRootForTest(dir) { _harnessDir = dir }
|
|
72
|
+
export function getHarnessDir() {
|
|
73
|
+
if (!_harnessDir) _harnessDir = resolveHarnessRoot()
|
|
74
|
+
return _harnessDir
|
|
75
|
+
}
|
|
76
|
+
function getRulesDir() { return path.join(getHarnessDir(), "rules") }
|
|
77
|
+
function getSkillsDir() { return path.join(getHarnessDir(), "skills") }
|
|
78
|
+
function getConstitutionFile() { return path.join(getHarnessDir(), "codex", "CONSTITUTION.md") }
|
|
79
|
+
function getRuntimeFile() { return path.join(getHarnessDir(), "instructions", "RUNTIME.md") }
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
function scanDirNames(dir) {
|
|
@@ -127,17 +132,17 @@ export function loadLocalSoulOverride(overridePath) {
|
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
function buildBootstrapContent() {
|
|
130
|
-
let constitution = fs.readFileSync(
|
|
135
|
+
let constitution = fs.readFileSync(getConstitutionFile(), "utf8")
|
|
131
136
|
const localOverride = loadLocalSoulOverride()
|
|
132
137
|
if (localOverride) {
|
|
133
138
|
constitution += `\n\n## Local Overrides (survives reinstalls)\n\n${localOverride}`
|
|
134
139
|
}
|
|
135
|
-
const runtime = fs.readFileSync(
|
|
136
|
-
const capMap = buildCapabilityMap(
|
|
140
|
+
const runtime = fs.readFileSync(getRuntimeFile(), "utf8")
|
|
141
|
+
const capMap = buildCapabilityMap(getHarnessDir())
|
|
137
142
|
|
|
138
143
|
const router = `## AGENTS.md
|
|
139
144
|
|
|
140
|
-
OpenHermes thin constitutional router. Full harness →
|
|
145
|
+
OpenHermes thin constitutional router. Full harness → \`openhermes/harness/\`.
|
|
141
146
|
|
|
142
147
|
## Constitution
|
|
143
148
|
|
|
@@ -145,7 +150,7 @@ Pragmatic. Concise. Task-oriented. Subagent-first. Inspect, then act. Scope to t
|
|
|
145
150
|
|
|
146
151
|
## Safety
|
|
147
152
|
|
|
148
|
-
Snapshot before mutation. Never delete unrelated files. Never assume \`%USERPROFILE
|
|
153
|
+
Snapshot before mutation. Never delete unrelated files. Never assume \`%USERPROFILE%\\.config\\opencode\` is a git repo. Verify or roll back. **NEVER delete \`auth.json\`** (\`%USERPROFILE%\\.local\\share\\opencode\\auth.json\`).
|
|
149
154
|
|
|
150
155
|
${capMap}
|
|
151
156
|
|
|
@@ -179,11 +184,11 @@ Main context = coordination + verification only. Substantive work → subagent.
|
|
|
179
184
|
| Rust build fix | \`build-rust\` |
|
|
180
185
|
| Any non-trivial multi-step | appropriate specialist |
|
|
181
186
|
|
|
182
|
-
Never delegate trivial single-step ops. Subagent returns diff + summary + verification; inspect return only. Full ref:
|
|
187
|
+
Never delegate trivial single-step ops. Subagent returns diff + summary + verification; inspect return only. Full ref: \`openhermes/harness/rules/delegation.md\`.
|
|
183
188
|
|
|
184
189
|
## Handoff Protocol
|
|
185
190
|
|
|
186
|
-
Every agent knows its role, permissions, and when to delegate. Before delegating, assess task complexity (easy → direct, medium → single subagent, hard → sequential multi-agent, very-large → fan-out). Use structured handoff format documented in
|
|
191
|
+
Every agent knows its role, permissions, and when to delegate. Before delegating, assess task complexity (easy → direct, medium → single subagent, hard → sequential multi-agent, very-large → fan-out). Use structured handoff format documented in \`openhermes/harness/rules/handoff.md\`.
|
|
187
192
|
|
|
188
193
|
- **Act**: Task matches your role and permissions → do it directly
|
|
189
194
|
- **Delegate**: Task outside your role → pass to correct agent via \`task\` tool
|
|
@@ -198,7 +203,7 @@ Every agent knows its role, permissions, and when to delegate. Before delegating
|
|
|
198
203
|
- **Before close**: Query same-type mistakes (7 days). Match → \`code-reviewer\` or \`security-reviewer\`.
|
|
199
204
|
- **On failure**: \`ohc_search\` for similar incidents. Search memory before asking user.
|
|
200
205
|
- **Precision ladder**: \`ohc_latest\` → \`ohc_search\` → \`ohc_get\` → \`ohc_list\` (last resort). Full index reads only for explicit audit/repair tasks.
|
|
201
|
-
- **Anti-spam**: No obvious facts, no one-off prefs, no temp state, no low-risk mistakes. Supersede, don't duplicate. Full rules:
|
|
206
|
+
- **Anti-spam**: No obvious facts, no one-off prefs, no temp state, no low-risk mistakes. Supersede, don't duplicate. Full rules: \`openhermes/harness/rules/retrieval.md\`, \`openhermes/harness/rules/memory-management.md\`.
|
|
202
207
|
|
|
203
208
|
## Self-Edit Authority
|
|
204
209
|
|
|
@@ -206,44 +211,44 @@ Every agent knows its role, permissions, and when to delegate. Before delegating
|
|
|
206
211
|
|------|-------------|----------------|
|
|
207
212
|
| Memory entries, mistakes, checkpoints, receipts | openhermes docs/schemas/templates/non-core rules patches | AGENTS.md core, model routing, permissions, config, protected settings |
|
|
208
213
|
|
|
209
|
-
Full tiers:
|
|
214
|
+
Full tiers: \`openhermes/harness/rules/self-heal.md\`.
|
|
210
215
|
|
|
211
216
|
## Precedence
|
|
212
217
|
|
|
213
|
-
1. User instruction. 2. Safety/legal/destructive guard. 3. Constitution (
|
|
218
|
+
1. User instruction. 2. Safety/legal/destructive guard. 3. Constitution (\`openhermes/harness/codex/\`). 4. Project constraints. 5. Project decisions. 6. Verified guards. 7. Checkpoints. 8. Instincts. 9. Freeform notes. Full: \`openhermes/harness/rules/precedence.md\`.
|
|
214
219
|
|
|
215
220
|
## Hygiene
|
|
216
221
|
|
|
217
222
|
- Checkpoint on meaningful boundaries. Compress closed segments immediately.
|
|
218
223
|
- After subagent return: verify → compress that block.
|
|
219
224
|
- Compress proactively.
|
|
220
|
-
- Skill candidates → \`/learn\` only if repeated pattern + \`ohc_search\` confirms no dup. See
|
|
221
|
-
- Audit triggers: openhermes/config change, repeated failures, session start when last audit >7 days. See
|
|
225
|
+
- Skill candidates → \`/learn\` only if repeated pattern + \`ohc_search\` confirms no dup. See \`openhermes/harness/rules/skills-management.md\`.
|
|
226
|
+
- Audit triggers: openhermes/config change, repeated failures, session start when last audit >7 days. See \`openhermes/harness/rules/audit.md\`.
|
|
222
227
|
|
|
223
228
|
## Escalation
|
|
224
229
|
|
|
225
|
-
T0: observe → log mistake → smallest fix. T1: add prevention rule → verify. T2: diagnosis/specialist → backlog. T3: constrained safe mode. Full:
|
|
230
|
+
T0: observe → log mistake → smallest fix. T1: add prevention rule → verify. T2: diagnosis/specialist → backlog. T3: constrained safe mode. Full: \`openhermes/harness/rules/self-heal.md\`.
|
|
226
231
|
|
|
227
232
|
## State
|
|
228
233
|
|
|
229
|
-
- **Config root**: \`%USERPROFILE
|
|
230
|
-
- **Auth**: \`%USERPROFILE
|
|
231
|
-
- **Forensic ledger**: \`%USERPROFILE
|
|
234
|
+
- **Config root**: \`%USERPROFILE%\\.config\\opencode\`
|
|
235
|
+
- **Auth**: \`%USERPROFILE%\\.local\\share\\opencode\\auth.json\` (NEVER delete)
|
|
236
|
+
- **Forensic ledger**: \`%USERPROFILE%\\.local\\share\\opencode\\opencode.db\``
|
|
232
237
|
|
|
233
238
|
return [
|
|
234
|
-
`<OPENHERMES_BOOTSTRAP>\nOpenHermes v${getOwnVersion()} active. Harness:
|
|
239
|
+
`<OPENHERMES_BOOTSTRAP>\nOpenHermes v${getOwnVersion()} active. Harness: \`openhermes/harness/\`. Memory at \`~/.local/share/opencode/openhermes/memory/\`. Rules at \`openhermes/harness/rules/\`. Skills discoverable via \`skill\` tool — use \`skill\` tool to list/load them.`,
|
|
235
240
|
`<OPENHERMES_CONSTITUTION>\n${constitution}\n</OPENHERMES_CONSTITUTION>`,
|
|
236
241
|
`<OPENHERMES_RUNTIME>\n${runtime}\n</OPENHERMES_RUNTIME>`,
|
|
237
242
|
`<OPENHERMES_ROUTER>\n${router}\n</OPENHERMES_ROUTER>`
|
|
238
243
|
].join("\n\n")
|
|
239
244
|
}
|
|
240
245
|
|
|
241
|
-
|
|
246
|
+
const OWN_VERSION = (() => {
|
|
242
247
|
try {
|
|
243
|
-
|
|
244
|
-
return pkg.version || "1.0.0"
|
|
248
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")).version || "1.0.0"
|
|
245
249
|
} catch { return "1.0.0" }
|
|
246
|
-
}
|
|
250
|
+
})()
|
|
251
|
+
function getOwnVersion() { return OWN_VERSION }
|
|
247
252
|
|
|
248
253
|
export const BootstrapPlugin = async ({ client, directory }) => {
|
|
249
254
|
let _bootstrapCache
|
|
@@ -263,14 +268,22 @@ export const BootstrapPlugin = async ({ client, directory }) => {
|
|
|
263
268
|
config: async (config) => {
|
|
264
269
|
config.skills = config.skills || {}
|
|
265
270
|
config.skills.paths = config.skills.paths || []
|
|
266
|
-
if (!config.skills.paths.includes(
|
|
267
|
-
config.skills.paths.push(
|
|
271
|
+
if (!config.skills.paths.includes(getSkillsDir())) {
|
|
272
|
+
config.skills.paths.push(getSkillsDir())
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
const PROMPTS_DIR = path.join(
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
275
|
+
const PROMPTS_DIR = path.join(getHarnessDir(), "prompts")
|
|
276
|
+
const COMMANDS_DIR = path.join(getHarnessDir(), "commands")
|
|
277
|
+
const ct = (file) => {
|
|
278
|
+
const fp = path.join(COMMANDS_DIR, file)
|
|
279
|
+
try { return fs.readFileSync(fp, "utf8").trimEnd() + "\n\n$ARGUMENTS" }
|
|
280
|
+
catch { return "$ARGUMENTS" }
|
|
281
|
+
}
|
|
282
|
+
const p = (name) => {
|
|
283
|
+
const fp = path.join(PROMPTS_DIR, name)
|
|
284
|
+
try { return fs.readFileSync(fp, "utf8").trimEnd() }
|
|
285
|
+
catch { return "" }
|
|
286
|
+
}
|
|
274
287
|
|
|
275
288
|
const existingCommands = config.command ?? {}
|
|
276
289
|
const existingAgents = { ...(config.agent ?? {}) }
|
package/curator.mjs
CHANGED
|
@@ -14,6 +14,28 @@ const lastCheckpoint = { ts: 0 }
|
|
|
14
14
|
const writtenThisSession = []
|
|
15
15
|
const CURATOR_LOGS = /^(1|true|yes)$/i.test(process.env.OPENCODE_CURATOR_LOGS || "")
|
|
16
16
|
|
|
17
|
+
const MEMORY_CLASSES = ["checkpoints", "mistakes", "audits", "verification_receipts", "constraints", "decisions", "instincts", "backlog"]
|
|
18
|
+
|
|
19
|
+
function ensureConsistency(root) {
|
|
20
|
+
const memoryRoot = path.join(root, "memory")
|
|
21
|
+
const runtimeRoot = path.join(root, "runtime")
|
|
22
|
+
|
|
23
|
+
for (const cls of MEMORY_CLASSES) {
|
|
24
|
+
const dir = path.join(memoryRoot, cls)
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
26
|
+
const indexPath = path.join(dir, "index.json")
|
|
27
|
+
if (!fs.existsSync(indexPath)) {
|
|
28
|
+
atomicWriteJson(indexPath, [])
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fs.mkdirSync(runtimeRoot, { recursive: true })
|
|
33
|
+
const loopStatePath = path.join(runtimeRoot, "loop-state.json")
|
|
34
|
+
if (!fs.existsSync(loopStatePath)) {
|
|
35
|
+
atomicWriteJson(loopStatePath, { status: "idle", phase: "session.created" })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
function curatorLog(message) {
|
|
18
40
|
if (!CURATOR_LOGS) return
|
|
19
41
|
process.stderr.write(`${message}\n`)
|
|
@@ -70,7 +92,7 @@ function loadSchema(classId) {
|
|
|
70
92
|
function validateRecordAgainstSchema(record) {
|
|
71
93
|
const schema = loadSchema(record.class)
|
|
72
94
|
if (!schema) {
|
|
73
|
-
curatorLog(`[curator] no schema
|
|
95
|
+
curatorLog(`[curator] validation fallback: no schema for "${record.class}"`)
|
|
74
96
|
const required = record.class === "checkpoint"
|
|
75
97
|
? ["id", "class", "summary", "mission", "current_state", "next_actions", "blockers", "risk_notes", "provenance", "created_at", "status"]
|
|
76
98
|
: ["id", "class", "summary", "provenance", "created_at", "status"]
|
|
@@ -80,19 +102,19 @@ function validateRecordAgainstSchema(record) {
|
|
|
80
102
|
return false
|
|
81
103
|
}
|
|
82
104
|
if (record.class === "checkpoint" && record.provenance && !record.provenance.session_id) {
|
|
83
|
-
curatorLog(`[curator] validation failed:
|
|
105
|
+
curatorLog(`[curator] validation failed: missing session_id`)
|
|
84
106
|
return false
|
|
85
107
|
}
|
|
86
108
|
return true
|
|
87
109
|
}
|
|
88
110
|
const unsupported = findUnsupportedSchemaKeywords(schema)
|
|
89
111
|
if (unsupported.length) {
|
|
90
|
-
curatorLog(`[curator]
|
|
112
|
+
curatorLog(`[curator] validation failed: unsupported fields ${unsupported.join(", ")}`)
|
|
91
113
|
return false
|
|
92
114
|
}
|
|
93
115
|
const errors = validateSchema(schema, record, "$")
|
|
94
116
|
if (errors.length) {
|
|
95
|
-
curatorLog(`[curator]
|
|
117
|
+
curatorLog(`[curator] validation failed: ${errors.join("; ")}`)
|
|
96
118
|
return false
|
|
97
119
|
}
|
|
98
120
|
if (record.class === "checkpoint") {
|
|
@@ -164,7 +186,6 @@ async function writeCheckpoint(root, project, directory, trigger, summary, optio
|
|
|
164
186
|
const safeRecord = sanitizeRecord(record, { maxStringLength: 4000 })
|
|
165
187
|
if (!validateRecordAgainstSchema(safeRecord)) return null
|
|
166
188
|
const dir = path.join(root, "memory", "checkpoints")
|
|
167
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
168
189
|
atomicWriteJson(path.join(dir, `${id}.json`), safeRecord)
|
|
169
190
|
indexEntry(root, "checkpoints", safeRecord)
|
|
170
191
|
updateLoopState(root, {
|
|
@@ -174,6 +195,7 @@ async function writeCheckpoint(root, project, directory, trigger, summary, optio
|
|
|
174
195
|
updated_at: ts,
|
|
175
196
|
status: trigger === "experimental.session.compacting" ? "active" : "idle",
|
|
176
197
|
})
|
|
198
|
+
if (writtenThisSession.length >= 100) writtenThisSession.shift()
|
|
177
199
|
writtenThisSession.push(id)
|
|
178
200
|
curatorLog(`[curator] checkpoint written: ${id} (trigger: ${trigger})`)
|
|
179
201
|
return id
|
|
@@ -261,7 +283,6 @@ function writeVerificationReceipt(root, project, directory, checkpointId) {
|
|
|
261
283
|
environment_fingerprint: environmentFingerprint,
|
|
262
284
|
}
|
|
263
285
|
const dir = path.join(root, "memory", "verification_receipts")
|
|
264
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
265
286
|
const safeRecord = sanitizeRecord(record, { maxStringLength: 4000 })
|
|
266
287
|
atomicWriteJson(path.join(dir, `${id}.json`), safeRecord)
|
|
267
288
|
indexEntry(root, "verification_receipts", safeRecord)
|
|
@@ -369,7 +390,6 @@ async function handlePermissionReplied(directory, project, event) {
|
|
|
369
390
|
}
|
|
370
391
|
}
|
|
371
392
|
const dir = path.join(root, "memory", "audits")
|
|
372
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
373
393
|
atomicWriteJson(path.join(dir, `${id}.json`), safeRecord)
|
|
374
394
|
indexEntry(root, "audits", safeRecord)
|
|
375
395
|
curatorLog(`[curator] permission audit logged: ${event.tool} -> ${event.action}`)
|
|
@@ -382,9 +402,16 @@ export const CuratorPlugin = async ({ project, directory }) => {
|
|
|
382
402
|
return {
|
|
383
403
|
event: async ({ event }) => {
|
|
384
404
|
// A1: v1 migration owned by autorecall.mjs — removed to avoid race
|
|
385
|
-
if (event.type === "session.created") {
|
|
386
|
-
lastCheckpoint.ts = 0
|
|
387
|
-
writtenThisSession.length = 0
|
|
405
|
+
if (event.type === "session.created") {
|
|
406
|
+
lastCheckpoint.ts = 0
|
|
407
|
+
writtenThisSession.length = 0
|
|
408
|
+
try {
|
|
409
|
+
const root = getDataRoot()
|
|
410
|
+
ensureConsistency(root)
|
|
411
|
+
curatorLog("[curator] consistency repair: all memory/runtime dirs verified")
|
|
412
|
+
} catch (err) {
|
|
413
|
+
curatorLog(`[curator] consistency repair failure: ${safeLogMessage(err.message)}`)
|
|
414
|
+
}
|
|
388
415
|
}
|
|
389
416
|
if (event.type === "session.idle") {
|
|
390
417
|
await handleSessionIdle(directory, project)
|
|
@@ -15,7 +15,7 @@ This creates "phantom" compressed data that references stale environments.
|
|
|
15
15
|
{
|
|
16
16
|
"fingerprint": {
|
|
17
17
|
"cwd": "C:/path/to/project",
|
|
18
|
-
"harness_root": "
|
|
18
|
+
"harness_root": "%USERPROFILE%\\.config\\opencode",
|
|
19
19
|
"project_root": "C:/path/to/project",
|
|
20
20
|
"project": "my-project",
|
|
21
21
|
"session_id": "session-123",
|
package/index.mjs
CHANGED
|
@@ -25,7 +25,9 @@ export default async (input) => {
|
|
|
25
25
|
OhcPlugin(input),
|
|
26
26
|
UpdaterPlugin(input),
|
|
27
27
|
])
|
|
28
|
-
const [
|
|
28
|
+
const pluginNames = ["Bootstrap", "Autorecall", "Curator", "SkillBuilder", "MemoryTools", "AmbientMemory", "Ohc", "Updater"]
|
|
29
|
+
results.forEach((r, i) => { if (r.status === "rejected") console.error(`[openhermes] ${pluginNames[i]} plugin failed:`, r.reason) })
|
|
30
|
+
const [bootstrap, autorecall, curator, skillBuilder, memoryTools, ambient, ohc, updater] = results.map(r => r.status === "fulfilled" ? r.value : {})
|
|
29
31
|
|
|
30
32
|
const merged = {}
|
|
31
33
|
|
package/lib/handoff.mjs
CHANGED
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
// Convention-based, no runtime deps. Agents import and call.
|
|
3
3
|
|
|
4
4
|
let _idSeq = 0
|
|
5
|
-
const COMPLEXITY_RULES = [
|
|
6
|
-
{ maxFiles: 2, patterns: 0, label: "easy" },
|
|
7
|
-
{ maxFiles: 10, patterns: 0, label: "medium" },
|
|
8
|
-
{ maxFiles: 60, patterns: 0, label: "hard" },
|
|
9
|
-
]
|
|
10
5
|
|
|
11
6
|
function nextId() {
|
|
12
7
|
_idSeq++
|
package/lib/hardening.mjs
CHANGED
|
@@ -3,10 +3,11 @@ import fs from "node:fs"
|
|
|
3
3
|
import os from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
5
|
|
|
6
|
-
const SECRET_KEY_PATTERN = /(token|secret|password|passwd|passphrase|api[-_ ]?key|access[-_ ]?key|refresh[-_ ]?token|authorization|cookie|bearer)/i
|
|
6
|
+
const SECRET_KEY_PATTERN = /(token|secret|password|passwd|passphrase|pwd|credential|auth|api[-_ ]?key|access[-_ ]?key|refresh[-_ ]?token|authorization|cookie|bearer)/i
|
|
7
7
|
const TEXT_REDACTIONS = [
|
|
8
8
|
[/\bsk-[A-Za-z0-9]{16,}\b/g, "[REDACTED]"],
|
|
9
9
|
[/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED]"],
|
|
10
|
+
[/\bgithub_pat_[A-Za-z0-9_]+\b/g, "[REDACTED]"],
|
|
10
11
|
[/\bxox[baprs]-[A-Za-z0-9-]+\b/g, "[REDACTED]"],
|
|
11
12
|
[/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, "Bearer [REDACTED]"],
|
|
12
13
|
[/\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]"],
|
|
@@ -111,7 +112,11 @@ function readJson(fp, fallback) {
|
|
|
111
112
|
|
|
112
113
|
function readJsonl(fp) {
|
|
113
114
|
try {
|
|
114
|
-
|
|
115
|
+
const results = []
|
|
116
|
+
for (const l of fs.readFileSync(fp, "utf8").trim().split("\n").filter(Boolean)) {
|
|
117
|
+
try { results.push(JSON.parse(l)) } catch {}
|
|
118
|
+
}
|
|
119
|
+
return results
|
|
115
120
|
} catch { return [] }
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -125,4 +130,17 @@ function buildEnvironmentFingerprint(root, directory, project) {
|
|
|
125
130
|
})
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
function toDisplayPath(absPath) {
|
|
134
|
+
if (!absPath) return ''
|
|
135
|
+
const normalized = absPath.replace(/\\/g, '/')
|
|
136
|
+
const idx = normalized.lastIndexOf('/openhermes/')
|
|
137
|
+
if (idx !== -1) return 'openhermes' + normalized.slice(idx + 11)
|
|
138
|
+
const parts = normalized.split('/').filter(Boolean)
|
|
139
|
+
return parts.slice(-2).join('/')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function branded(prefix, message) {
|
|
143
|
+
return `OpenHermes${prefix ? ` ${prefix}` : ''}: ${message}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { atomicWriteJson, branded, buildEnvironmentFingerprint, fingerprintEnvironment, fingerprintFile, isPlainObject, isTruthy, readJson, readJsonl, redactSensitiveText, sanitizeRecord, toDisplayPath, truncateText }
|
|
@@ -37,6 +37,11 @@ function buildEntry(cls, r) {
|
|
|
37
37
|
const e = { id: r.id, summary: r.summary, status: r.status, updated_at: r.updated_at ?? r.created_at, path: path.join("openhermes", "memory", PLURALS[cls], `${r.id}.json`), scope: r.scope ?? null, project: r.project ?? null }
|
|
38
38
|
if (cls === "audit") { e.target = r.target; e.overall_score = r.overall_score }
|
|
39
39
|
if (cls === "backlog") { e.priority = r.priority; e.trigger = r.trigger }
|
|
40
|
+
if (cls === "decision") { e.choice = r.choice; e.reason = r.reason }
|
|
41
|
+
if (cls === "constraint") { e.description = r.description }
|
|
42
|
+
if (cls === "instinct") { e.source = r.source }
|
|
43
|
+
if (cls === "checkpoint") { e.mission = r.mission }
|
|
44
|
+
if (cls === "verification_receipt") { e.artifact = r.artifact; e.result = r.result }
|
|
40
45
|
return e
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -58,6 +63,7 @@ function sortRecent(entries) {
|
|
|
58
63
|
|
|
59
64
|
function filterActive(entries) { return entries.filter(e => !hasExpired(e)) }
|
|
60
65
|
|
|
66
|
+
// CAUTION: read-modify-write — safe within single session; concurrent sessions may race
|
|
61
67
|
function writeObject(cls, record) {
|
|
62
68
|
const dir = classDir(cls)
|
|
63
69
|
fs.mkdirSync(dir, { recursive: true })
|
|
@@ -80,7 +86,9 @@ function upsertMistake(record) {
|
|
|
80
86
|
const idx = entries.findIndex(e => e?.id === record.id)
|
|
81
87
|
if (idx >= 0) entries[idx] = record; else entries.push(record)
|
|
82
88
|
const text = entries.map(e => stableStringify(e)).join("\n")
|
|
83
|
-
|
|
89
|
+
const tmp = fp + ".tmp"
|
|
90
|
+
fs.writeFileSync(tmp, text ? `${text}\n` : "", "utf8")
|
|
91
|
+
fs.renameSync(tmp, fp)
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
function queryList(cls, limit = 10) {
|
|
@@ -174,10 +182,6 @@ function handleAdd(cls, id, dataStr) {
|
|
|
174
182
|
if (!prov.file_refs.some(r => typeof r === "string" && r.trim())) prov.file_refs.push("auto-filled")
|
|
175
183
|
}
|
|
176
184
|
}
|
|
177
|
-
if (required.includes("trigger") && !record.trigger) record.trigger = "manual"
|
|
178
|
-
if (required.includes("success_count") && record.success_count == null) record.success_count = 0
|
|
179
|
-
if (required.includes("failure_count") && record.failure_count == null) record.failure_count = 0
|
|
180
|
-
if (required.includes("promotion_state") && !record.promotion_state) record.promotion_state = "project"
|
|
181
185
|
|
|
182
186
|
const unsupported = findUnsupportedSchemaKeywords(schema)
|
|
183
187
|
if (unsupported.length) return `unsupported schema: ${unsupported.join(", ")}`
|
|
@@ -211,7 +215,7 @@ function handleLatest(cls) {
|
|
|
211
215
|
const list = queryList(cls, 100)
|
|
212
216
|
const active = filterActive(list)
|
|
213
217
|
if (!active[0]?.id) return "no active records"
|
|
214
|
-
const record = queryGet(cls, active[0].id)
|
|
218
|
+
const record = cls === "mistake" ? active[0] : queryGet(cls, active[0].id)
|
|
215
219
|
if (!record) return "no active records"
|
|
216
220
|
return stableStringify(record)
|
|
217
221
|
}
|
|
@@ -246,13 +250,12 @@ function handleSearch(query, scope, classes, project, limit) {
|
|
|
246
250
|
.sort((a, b) => b.score - a.score)
|
|
247
251
|
.slice(0, lim)
|
|
248
252
|
|
|
249
|
-
// Fetch full records for top results to get richer fields
|
|
250
253
|
const results = scored.map(e => {
|
|
251
254
|
if (e._cls === "mistake") return e
|
|
252
255
|
const full = queryGet(e._cls, e.id)
|
|
253
|
-
return full
|
|
256
|
+
return full ? { ...full, score: e.score } : e
|
|
254
257
|
})
|
|
255
|
-
|
|
258
|
+
|
|
256
259
|
const lines = results.map(e => ` ${e.id} (${e.score}pt): ${truncateText(e.summary || "", 80)}`)
|
|
257
260
|
return `${results.length} result${results.length === 1 ? "" : "s"} for '${q}'\n` + lines.join("\n")
|
|
258
261
|
}
|
|
@@ -13,7 +13,7 @@ export function allocateRunId(state) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function wrapBlockSummary(blockId, summary) {
|
|
16
|
-
return `[
|
|
16
|
+
return `[Compressed bk${blockId}]\n\n${summary}\n<ohc-ref>bk${blockId}</ohc-ref>`
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function applyCompressionState(state, input, selection, anchorMessageId, blockId, storedSummary, consumedBlockIds) {
|
|
@@ -33,7 +33,7 @@ export function applyCompressionState(state, input, selection, anchorMessageId,
|
|
|
33
33
|
endId: input.endId,
|
|
34
34
|
summary: storedSummary,
|
|
35
35
|
summaryTokens: input.summaryTokens || 0,
|
|
36
|
-
compressedTokens: 0,
|
|
36
|
+
compressedTokens: input.compressedTokens || 0,
|
|
37
37
|
consumedBlockIds: Array.isArray(consumedBlockIds) ? consumedBlockIds : [],
|
|
38
38
|
deactivatedByBlockId: undefined,
|
|
39
39
|
deactivatedByUser: false,
|
package/lib/ohc/config.mjs
CHANGED
|
@@ -16,8 +16,7 @@ const DEFAULTS = {
|
|
|
16
16
|
turnProtection: { enabled: false, turns: 4 },
|
|
17
17
|
protectedFilePatterns: [],
|
|
18
18
|
compress: {
|
|
19
|
-
|
|
20
|
-
minContextLimit: 50000,
|
|
19
|
+
|
|
21
20
|
nudgeFrequency: 5,
|
|
22
21
|
iterationNudgeThreshold: 15,
|
|
23
22
|
nudgeForce: "soft",
|
|
@@ -99,8 +98,6 @@ function mergeTurnProtection(base, override) {
|
|
|
99
98
|
function mergeCompress(base, override) {
|
|
100
99
|
if (!override || typeof override !== "object") return base
|
|
101
100
|
return {
|
|
102
|
-
maxContextLimit: override.maxContextLimit ?? base.maxContextLimit,
|
|
103
|
-
minContextLimit: override.minContextLimit ?? base.minContextLimit,
|
|
104
101
|
nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
|
|
105
102
|
iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
|
|
106
103
|
nudgeForce: override.nudgeForce ?? base.nudgeForce,
|
|
@@ -175,6 +172,8 @@ export function loadConfig(ctx) {
|
|
|
175
172
|
config.max = typeof config.max === "number" && config.max > 0 ? config.max : DEFAULTS.max
|
|
176
173
|
config.min = typeof config.min === "number" ? Math.max(10000, Math.min(config.min, config.max - 10000)) : DEFAULTS.min
|
|
177
174
|
|
|
175
|
+
try { writeDefaults() } catch { /* best-effort */ }
|
|
176
|
+
|
|
178
177
|
return config
|
|
179
178
|
}
|
|
180
179
|
|
package/lib/ohc/message-ids.mjs
CHANGED
|
@@ -114,6 +114,16 @@ function allocateNextRef(state) {
|
|
|
114
114
|
return formatMessageRef(1)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export function cleanupMessageRefs(state, removedRawIds) {
|
|
118
|
+
for (const rawId of removedRawIds) {
|
|
119
|
+
const ref = state.messageIds.byRawId.get(rawId)
|
|
120
|
+
if (ref) {
|
|
121
|
+
state.messageIds.byRawId.delete(rawId)
|
|
122
|
+
state.messageIds.byRef.delete(ref)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
export function injectMessageIds(state, messages) {
|
|
118
128
|
for (const msg of messages) {
|
|
119
129
|
if (isIgnoredUserMessage(msg)) continue
|
package/lib/ohc/notify.mjs
CHANGED
|
@@ -1,56 +1,50 @@
|
|
|
1
1
|
function formatTokenCount(tokens) {
|
|
2
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`.replace(".0M", "M")
|
|
2
3
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K")
|
|
3
4
|
return String(tokens)
|
|
4
5
|
}
|
|
5
6
|
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (total === 0) return `\u2502${"\u2591".repeat(width)}\u2502 0% active`
|
|
10
|
-
const activeRatio = visibleCount / total
|
|
11
|
-
const activeW = Math.round(activeRatio * width)
|
|
12
|
-
const prunedW = width - activeW
|
|
13
|
-
const bar = "\u2588".repeat(Math.min(activeW, width)) + "\u2591".repeat(Math.min(prunedW, width))
|
|
14
|
-
return `\u2502${bar.slice(0, width)}\u2502 ${Math.round(activeRatio * 100)}% active`
|
|
7
|
+
function truncate(str, max) {
|
|
8
|
+
if (!str) return ""
|
|
9
|
+
return str.length > max ? str.slice(0, max) + "\u2026" : str
|
|
15
10
|
}
|
|
16
11
|
|
|
17
|
-
function
|
|
18
|
-
const
|
|
19
|
-
|
|
12
|
+
function buildCompressionDetailed(count, savedTotal, blockCount, summary) {
|
|
13
|
+
const parts = [
|
|
14
|
+
`${formatTokenCount(savedTotal)} saved \u00b7 ${count} msg${count === 1 ? "" : "s"} removed \u00b7 #${blockCount} block${blockCount === 1 ? "" : "s"}`,
|
|
15
|
+
]
|
|
16
|
+
if (summary) parts.push(truncate(summary, 48))
|
|
17
|
+
return parts.join("\n")
|
|
20
18
|
}
|
|
21
19
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
let msg = `\u25A3 OHC | ~${formatTokenCount(savedTotal)} saved total`
|
|
25
|
-
if (prunedCount + visibleCount > 0) {
|
|
26
|
-
msg += `\n\n${buildProgressBar(prunedCount, visibleCount)}`
|
|
27
|
-
}
|
|
28
|
-
msg += `\n\n\u25A3 ${label} #${blockCount}`
|
|
29
|
-
msg += `\n\u2192 ${count} message${count === 1 ? "" : "s"} removed`
|
|
30
|
-
if (summary) msg += `\n\u2192 Summary: ${summary}`
|
|
31
|
-
return msg
|
|
20
|
+
function buildCompressionMinimal(count, savedTotal, blockCount) {
|
|
21
|
+
return `${formatTokenCount(savedTotal)} saved \u00b7 ${blockCount} block${blockCount === 1 ? "" : "s"} \u00b7 ${count} msg${count === 1 ? "" : "s"} removed`
|
|
32
22
|
}
|
|
33
23
|
|
|
34
|
-
function
|
|
35
|
-
|
|
24
|
+
function buildMemoryMessage(action, cls, id, summary) {
|
|
25
|
+
const idPart = id ? truncate(id, 28) : ""
|
|
26
|
+
return [action, cls, idPart].filter(Boolean).join(" \u00b7 ")
|
|
36
27
|
}
|
|
37
28
|
|
|
38
|
-
export async function sendCompressNotification(client, sessionId, config, count, summary, tokensRemoved,
|
|
29
|
+
export async function sendCompressNotification(client, sessionId, config, count, summary, tokensRemoved, ss, currentMessageCount) {
|
|
39
30
|
if (count === 0) return false
|
|
40
31
|
|
|
41
|
-
const
|
|
42
|
-
const
|
|
32
|
+
const savedTotal = ss?.totalTokensSaved || 0
|
|
33
|
+
const blockCount = ss?.blockCount || 0
|
|
34
|
+
|
|
35
|
+
const notifType = config.notification ?? "chat"
|
|
36
|
+
const notifMode = config.notificationMode ?? "detailed"
|
|
43
37
|
|
|
44
38
|
if (notifType === "off") return false
|
|
45
39
|
|
|
46
40
|
if (notifType === "toast") {
|
|
47
41
|
const message = notifMode === "minimal"
|
|
48
|
-
?
|
|
49
|
-
:
|
|
42
|
+
? buildCompressionMinimal(count, savedTotal, blockCount)
|
|
43
|
+
: buildCompressionDetailed(count, savedTotal, blockCount, summary)
|
|
50
44
|
try {
|
|
51
45
|
await client.tui.showToast({
|
|
52
46
|
body: {
|
|
53
|
-
title: "OHC
|
|
47
|
+
title: "OHC Compression",
|
|
54
48
|
message,
|
|
55
49
|
variant: "info",
|
|
56
50
|
duration: 5000,
|
|
@@ -60,12 +54,15 @@ export async function sendCompressNotification(client, sessionId, config, count,
|
|
|
60
54
|
return true
|
|
61
55
|
}
|
|
62
56
|
|
|
57
|
+
const message = notifMode === "minimal"
|
|
58
|
+
? buildCompressionMinimal(count, savedTotal, blockCount)
|
|
59
|
+
: buildCompressionDetailed(count, savedTotal, blockCount, summary)
|
|
63
60
|
try {
|
|
64
61
|
await client.session.prompt({
|
|
65
62
|
path: { id: sessionId },
|
|
66
63
|
body: {
|
|
67
64
|
noReply: true,
|
|
68
|
-
parts: [{ type: "text", text:
|
|
65
|
+
parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
|
|
69
66
|
},
|
|
70
67
|
})
|
|
71
68
|
} catch {}
|
|
@@ -74,16 +71,16 @@ export async function sendCompressNotification(client, sessionId, config, count,
|
|
|
74
71
|
|
|
75
72
|
export async function sendStrategyNotification(client, sessionId, config, strategy, count, detail) {
|
|
76
73
|
if (count === 0) return false
|
|
77
|
-
const notifType = config.notification ?? "
|
|
74
|
+
const notifType = config.notification ?? "chat"
|
|
78
75
|
if (notifType === "off") return false
|
|
79
76
|
|
|
80
|
-
const message =
|
|
77
|
+
const message = detail ? `${strategy} \u2014 ${count} pruned \u00b7 ${truncate(detail, 36)}` : `${strategy} \u2014 ${count} pruned`
|
|
81
78
|
|
|
82
79
|
if (notifType === "toast") {
|
|
83
80
|
try {
|
|
84
81
|
await client.tui.showToast({
|
|
85
82
|
body: {
|
|
86
|
-
title:
|
|
83
|
+
title: "OHC Strategy",
|
|
87
84
|
message,
|
|
88
85
|
variant: "info",
|
|
89
86
|
duration: 3000,
|
|
@@ -98,7 +95,7 @@ export async function sendStrategyNotification(client, sessionId, config, strate
|
|
|
98
95
|
path: { id: sessionId },
|
|
99
96
|
body: {
|
|
100
97
|
noReply: true,
|
|
101
|
-
parts: [{ type: "text", text: message
|
|
98
|
+
parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
|
|
102
99
|
},
|
|
103
100
|
})
|
|
104
101
|
} catch {}
|
|
@@ -107,27 +104,15 @@ export async function sendStrategyNotification(client, sessionId, config, strate
|
|
|
107
104
|
|
|
108
105
|
export async function sendMemoryNotification(client, sessionId, config, action, cls, id, summary) {
|
|
109
106
|
const notifType = config.notification ?? "toast"
|
|
110
|
-
const notifMode = config.notificationMode ?? "minimal"
|
|
111
|
-
|
|
112
107
|
if (notifType === "off") return false
|
|
113
108
|
|
|
114
|
-
const
|
|
115
|
-
const buildDetailed = (action, cls, id, summary) => {
|
|
116
|
-
let msg = `\u25A3 Memory ${action}`
|
|
117
|
-
if (cls) msg += `\n\u2192 Class: ${cls}`
|
|
118
|
-
if (id) msg += `\n\u2192 ID: ${id}`
|
|
119
|
-
if (summary) msg += `\n\u2192 ${summary}`
|
|
120
|
-
return msg
|
|
121
|
-
}
|
|
109
|
+
const message = buildMemoryMessage(action, cls, id, summary)
|
|
122
110
|
|
|
123
111
|
if (notifType === "toast") {
|
|
124
|
-
const message = notifMode === "minimal"
|
|
125
|
-
? buildHeader(action, summary)
|
|
126
|
-
: buildDetailed(action, cls, id, summary)
|
|
127
112
|
try {
|
|
128
113
|
await client.tui.showToast({
|
|
129
114
|
body: {
|
|
130
|
-
title: "Memory",
|
|
115
|
+
title: "OHC Memory",
|
|
131
116
|
message,
|
|
132
117
|
variant: "info",
|
|
133
118
|
duration: 4000,
|
|
@@ -142,7 +127,7 @@ export async function sendMemoryNotification(client, sessionId, config, action,
|
|
|
142
127
|
path: { id: sessionId },
|
|
143
128
|
body: {
|
|
144
129
|
noReply: true,
|
|
145
|
-
parts: [{ type: "text", text:
|
|
130
|
+
parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
|
|
146
131
|
},
|
|
147
132
|
})
|
|
148
133
|
} catch {}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const DEFAULT_PROTECTED_TOOLS = new Set([
|
|
2
2
|
"task", "skill", "todowrite", "todoread",
|
|
3
3
|
"compress", "batch", "plan_enter", "plan_exit",
|
|
4
|
-
"write", "edit",
|
|
4
|
+
"write", "edit", "bash", "webfetch",
|
|
5
5
|
])
|
|
6
6
|
|
|
7
7
|
export function isToolNameProtected(toolName, extraProtected = []) {
|
|
@@ -34,7 +34,8 @@ export function isFilePathProtected(filePaths, protectedPatterns = []) {
|
|
|
34
34
|
function globMatch(filePath, pattern) {
|
|
35
35
|
const regexStr = pattern
|
|
36
36
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
37
|
-
.replace(
|
|
37
|
+
.replace(/\*\*/g, ".*")
|
|
38
|
+
.replace(/\*/g, "[^/]*")
|
|
38
39
|
.replace(/\?/g, ".")
|
|
39
40
|
try {
|
|
40
41
|
return new RegExp(`^${regexStr}$`, "i").test(filePath)
|
package/lib/ohc/pruner.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { loadConfig } from "./config.mjs"
|
|
3
|
-
import { selectMessagesToReap, totalTokens } from "./reaper.mjs"
|
|
3
|
+
import { selectMessagesToReap, totalTokens, msgTokens } from "./reaper.mjs"
|
|
4
4
|
import {
|
|
5
5
|
loadOhcState, saveOhcState, createSessionState,
|
|
6
6
|
serializeState, deserializeState,
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
} from "./state.mjs"
|
|
9
9
|
import { sendCompressNotification } from "./notify.mjs"
|
|
10
10
|
import { deduplicate, purgeErrors } from "./strategies/index.mjs"
|
|
11
|
-
import { applyPruneTools, filterCompressedBlocks } from "./prune-apply.mjs"
|
|
12
|
-
import { assignMessageRefs, injectMessageIds } from "./message-ids.mjs"
|
|
11
|
+
import { applyPruneTools, filterCompressedBlocks, applyFullToolRemoval } from "./prune-apply.mjs"
|
|
12
|
+
import { assignMessageRefs, injectMessageIds, cleanupMessageRefs } from "./message-ids.mjs"
|
|
13
13
|
import { syncCompressionBlocks } from "./block-sync.mjs"
|
|
14
14
|
import { buildSearchContext, resolveBoundaryIds, resolveSelection, resolveAnchorMessageId, validateNonOverlapping } from "./compress/search.mjs"
|
|
15
15
|
import { allocateBlockId, allocateRunId, wrapBlockSummary, applyCompressionState } from "./compress/state.mjs"
|
|
@@ -38,16 +38,16 @@ function getSessionId(messages) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function buildNudge(pct, max) {
|
|
41
|
-
if (pct > 0.95) return `
|
|
42
|
-
if (pct > 0.85) return `
|
|
43
|
-
if (pct > 0.70) return `
|
|
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
44
|
return null
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function summarizeRemoved(selected, summary) {
|
|
48
48
|
const n = selected.length
|
|
49
|
-
if (summary) return `[
|
|
50
|
-
return `[Auto-pruned
|
|
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
51
|
}
|
|
52
52
|
|
|
53
53
|
function createSummaryMessage(text) {
|
|
@@ -75,7 +75,6 @@ async function applyCompress(ctx, sessionId, summary, max, min, targetTokens) {
|
|
|
75
75
|
for (const r of selected) ss.prunedIds.add(r.id)
|
|
76
76
|
ss.summary = summarizeRemoved(selected, summary)
|
|
77
77
|
ss.anchorMessageId = selected[0].id
|
|
78
|
-
saveOhcState(sessionId, serializeState(ss))
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
const tokensRemoved = selected.reduce((s, r) => s + r.tokens, 0)
|
|
@@ -84,6 +83,8 @@ async function applyCompress(ctx, sessionId, summary, max, min, targetTokens) {
|
|
|
84
83
|
if (ss) {
|
|
85
84
|
ss.blockCount++
|
|
86
85
|
ss.totalTokensSaved += tokensRemoved
|
|
86
|
+
ss.totalMessagesRemoved += selected.length
|
|
87
|
+
saveOhcState(sessionId, serializeState(ss))
|
|
87
88
|
}
|
|
88
89
|
return { removed: selected.length, afterTotal, tokensRemoved, beforeTotal, beforeCount: msgs.length, afterCount: msgs.length - selected.length }
|
|
89
90
|
}
|
|
@@ -98,7 +99,7 @@ function estimateSummaryTokens(messages) {
|
|
|
98
99
|
for (const m of messages) {
|
|
99
100
|
if (m.info?.role === "system" && Array.isArray(m.parts)) {
|
|
100
101
|
for (const p of m.parts) {
|
|
101
|
-
if (p.type === "text" && p.text?.startsWith("[Compressed
|
|
102
|
+
if (p.type === "text" && (p.text?.startsWith("[OHC: Compressed]") || p.text?.startsWith("[OHC: Auto-pruned]"))) {
|
|
102
103
|
total += Math.ceil(p.text.length / 4)
|
|
103
104
|
}
|
|
104
105
|
}
|
|
@@ -133,12 +134,21 @@ async function executeRangeCompress(ctx, sessionId, callId, topic, content) {
|
|
|
133
134
|
|
|
134
135
|
const runId = allocateRunId(ss)
|
|
135
136
|
const notifications = []
|
|
137
|
+
let totalActualTokensRemoved = 0
|
|
138
|
+
const allMessageIds = []
|
|
136
139
|
|
|
137
140
|
for (const plan of plans) {
|
|
138
141
|
const blockId = allocateBlockId(ss)
|
|
139
142
|
const storedSummary = wrapBlockSummary(blockId, plan.entry.summary)
|
|
140
143
|
const summaryTokens = Math.ceil(storedSummary.length / 4)
|
|
141
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
|
+
|
|
142
152
|
applyCompressionState(
|
|
143
153
|
ss,
|
|
144
154
|
{
|
|
@@ -151,6 +161,7 @@ async function executeRangeCompress(ctx, sessionId, callId, topic, content) {
|
|
|
151
161
|
compressMessageId: plan.selection.messageIds[0],
|
|
152
162
|
compressCallId: callId,
|
|
153
163
|
summaryTokens,
|
|
164
|
+
compressedTokens: actualTokensRemoved,
|
|
154
165
|
},
|
|
155
166
|
plan.selection,
|
|
156
167
|
plan.anchorMessageId,
|
|
@@ -160,7 +171,8 @@ async function executeRangeCompress(ctx, sessionId, callId, topic, content) {
|
|
|
160
171
|
)
|
|
161
172
|
|
|
162
173
|
ss.blockCount++
|
|
163
|
-
ss.totalTokensSaved +=
|
|
174
|
+
ss.totalTokensSaved += actualTokensRemoved
|
|
175
|
+
ss.totalMessagesRemoved += plan.selection.messageIds.length
|
|
164
176
|
|
|
165
177
|
notifications.push({
|
|
166
178
|
blockId,
|
|
@@ -170,11 +182,14 @@ async function executeRangeCompress(ctx, sessionId, callId, topic, content) {
|
|
|
170
182
|
})
|
|
171
183
|
}
|
|
172
184
|
|
|
185
|
+
saveOhcState(sessionId, serializeState(ss))
|
|
186
|
+
|
|
173
187
|
return {
|
|
174
|
-
messageIds:
|
|
175
|
-
compressedTokens:
|
|
188
|
+
messageIds: allMessageIds,
|
|
189
|
+
compressedTokens: totalActualTokensRemoved,
|
|
176
190
|
summaryRef: content[0]?.summary || topic,
|
|
177
191
|
blockCount: plans.length,
|
|
192
|
+
afterCount: rawMessages.length - allMessageIds.length,
|
|
178
193
|
}
|
|
179
194
|
}
|
|
180
195
|
|
|
@@ -194,7 +209,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
194
209
|
const total = ss.compressionTiming.totalDurationMs || 0
|
|
195
210
|
const lastSec = (last / 1000).toFixed(1)
|
|
196
211
|
const totalSec = (total / 1000).toFixed(1)
|
|
197
|
-
return
|
|
212
|
+
return ` (last: ${lastSec}s, total: ${totalSec}s)`
|
|
198
213
|
}
|
|
199
214
|
|
|
200
215
|
function computeRoleBreakdown(messages) {
|
|
@@ -233,11 +248,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
233
248
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
234
249
|
if (systemInjected || !output.system?.length) return
|
|
235
250
|
systemInjected = true
|
|
236
|
-
|
|
237
|
-
const protectedToolsList = (config.compress?.protectedTools || []).length
|
|
238
|
-
? ` Protected tools (${(config.compress.protectedTools).join(", ")}) are preserved.`
|
|
239
|
-
: ``
|
|
240
|
-
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`
|
|
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`
|
|
241
252
|
},
|
|
242
253
|
|
|
243
254
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
@@ -261,6 +272,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
261
272
|
deduplicate(ss, config, output.messages)
|
|
262
273
|
purgeErrors(ss, config, output.messages)
|
|
263
274
|
applyPruneTools(ss, output.messages)
|
|
275
|
+
applyFullToolRemoval(ss, output.messages)
|
|
264
276
|
|
|
265
277
|
const now = Date.now()
|
|
266
278
|
const recentlyPruned = ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) < AUTOPRUNE_COOLDOWN
|
|
@@ -268,6 +280,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
268
280
|
if (ss.prunedIds.size > 0 && !recentlyPruned) {
|
|
269
281
|
const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
|
|
270
282
|
if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
|
|
283
|
+
cleanupMessageRefs(ss, ss.prunedIds)
|
|
271
284
|
ss.prunedIds.clear()
|
|
272
285
|
ss.summary = null
|
|
273
286
|
ss.anchorMessageId = null
|
|
@@ -283,10 +296,11 @@ export const OhcPlugin = async (ctx) => {
|
|
|
283
296
|
for (const r of selected) ss.prunedIds.add(r.id)
|
|
284
297
|
if (!ss.summary) ss.summary = summarizeRemoved(selected, null)
|
|
285
298
|
if (!ss.anchorMessageId) ss.anchorMessageId = selected[0].id
|
|
286
|
-
saveOhcState(sessionId, serializeState(ss))
|
|
287
299
|
const tokensRemoved = selected.reduce((s, r) => s + r.tokens, 0)
|
|
288
300
|
ss.blockCount++
|
|
289
301
|
ss.totalTokensSaved += tokensRemoved
|
|
302
|
+
ss.totalMessagesRemoved += selected.length
|
|
303
|
+
saveOhcState(sessionId, serializeState(ss))
|
|
290
304
|
ss.lastAutoPruneAt = now
|
|
291
305
|
ss._pruneCycleDone = true
|
|
292
306
|
}
|
|
@@ -342,7 +356,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
342
356
|
const iterationThreshold = config.compress?.iterationNudgeThreshold ?? 15
|
|
343
357
|
const iterCount = countIterationsSinceLastUser(output.messages)
|
|
344
358
|
if (iterCount >= iterationThreshold && iterCount % 5 === 0) {
|
|
345
|
-
const iterNudge = `
|
|
359
|
+
const iterNudge = `OHC: ${iterCount} AI turns since your last input. Summarize with \`compress\`.`
|
|
346
360
|
if (nudgeText) nudgeText += "\n\n" + iterNudge
|
|
347
361
|
else nudgeText = iterNudge
|
|
348
362
|
}
|
|
@@ -415,8 +429,12 @@ export const OhcPlugin = async (ctx) => {
|
|
|
415
429
|
const strategyPruned = ss?.prune?.tools?.size || 0
|
|
416
430
|
const timing = buildTimingStr(ss)
|
|
417
431
|
const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
|
|
418
|
-
const blockLine = activeBlockIds.length ? `
|
|
419
|
-
const
|
|
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}`
|
|
420
438
|
await ctx.client.session.prompt({
|
|
421
439
|
path: { id: input.sessionID },
|
|
422
440
|
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
@@ -432,8 +450,8 @@ export const OhcPlugin = async (ctx) => {
|
|
|
432
450
|
const autoPruned = ss?.prunedIds?.size || 0
|
|
433
451
|
const timing = buildTimingStr(ss)
|
|
434
452
|
const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
|
|
435
|
-
const blockLine = activeBlockIds.length ? `
|
|
436
|
-
const text = `
|
|
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}`
|
|
437
455
|
await ctx.client.session.prompt({
|
|
438
456
|
path: { id: input.sessionID },
|
|
439
457
|
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
@@ -448,14 +466,14 @@ export const OhcPlugin = async (ctx) => {
|
|
|
448
466
|
if (ss) ss.manualMode = "active"
|
|
449
467
|
await ctx.client.session.prompt({
|
|
450
468
|
path: { id: input.sessionID },
|
|
451
|
-
body: { noReply: true, parts: [{ type: "text", text: "
|
|
469
|
+
body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: active \u2014 agent will not autonomously compress", ignored: true }] },
|
|
452
470
|
})
|
|
453
471
|
} else {
|
|
454
472
|
const ss = getOrCreateState(input.sessionID)
|
|
455
473
|
if (ss) ss.manualMode = false
|
|
456
474
|
await ctx.client.session.prompt({
|
|
457
475
|
path: { id: input.sessionID },
|
|
458
|
-
body: { noReply: true, parts: [{ type: "text", text: "
|
|
476
|
+
body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: off \u2014 agent can compress autonomously", ignored: true }] },
|
|
459
477
|
})
|
|
460
478
|
}
|
|
461
479
|
throw new Error("__OHC_MANUAL_HANDLED__")
|
|
@@ -475,11 +493,11 @@ export const OhcPlugin = async (ctx) => {
|
|
|
475
493
|
try {
|
|
476
494
|
const result = await applyCompress(ctx, input.sessionID, focus, max, min, targetTokens)
|
|
477
495
|
const cmdSs = getOrCreateState(input.sessionID)
|
|
478
|
-
await sendCompressNotification(ctx.client, input.sessionID, config, result.removed, focus, result.tokensRemoved, cmdSs
|
|
496
|
+
await sendCompressNotification(ctx.client, input.sessionID, config, result.removed, focus, result.tokensRemoved, cmdSs, result.afterCount)
|
|
479
497
|
output.parts.length = 0
|
|
480
498
|
output.parts.push({
|
|
481
499
|
type: "text",
|
|
482
|
-
text: `
|
|
500
|
+
text: `OHC: Compressed ${result.removed} msgs. Summary: ${focus}`,
|
|
483
501
|
})
|
|
484
502
|
} catch {
|
|
485
503
|
output.parts.length = 0
|
|
@@ -513,8 +531,8 @@ export const OhcPlugin = async (ctx) => {
|
|
|
513
531
|
}
|
|
514
532
|
|
|
515
533
|
const activeBlockIds = [...(ss?.prune?.messages?.activeBlockIds || [])].filter(id => Number.isInteger(id)).sort((a, b) => a - b)
|
|
516
|
-
const blockLine = activeBlockIds.length ? `
|
|
517
|
-
const text = `
|
|
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")}`
|
|
518
536
|
await ctx.client.session.prompt({
|
|
519
537
|
path: { id: input.sessionID },
|
|
520
538
|
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
@@ -539,13 +557,13 @@ export const OhcPlugin = async (ctx) => {
|
|
|
539
557
|
}
|
|
540
558
|
}
|
|
541
559
|
applyPruneTools(ss, output.messages)
|
|
542
|
-
const text = `
|
|
560
|
+
const text = `OHC Sweep: ${sweptCount} tool call${sweptCount === 1 ? "" : "s"} pruned`
|
|
543
561
|
output.parts.length = 0
|
|
544
562
|
output.parts.push({ type: "text", text })
|
|
545
563
|
return
|
|
546
564
|
}
|
|
547
565
|
|
|
548
|
-
const text = "OHC
|
|
566
|
+
const text = "OHC: /ohc status | stats | context | sweep [n] | manual [on|off] | compress [focus]"
|
|
549
567
|
await ctx.client.session.prompt({
|
|
550
568
|
path: { id: input.sessionID },
|
|
551
569
|
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
@@ -555,7 +573,7 @@ export const OhcPlugin = async (ctx) => {
|
|
|
555
573
|
|
|
556
574
|
tool: {
|
|
557
575
|
compress: tool({
|
|
558
|
-
description: "Compress conversation content to free context space.
|
|
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.",
|
|
559
577
|
args: {
|
|
560
578
|
topic: tool.schema.string().optional().describe("Range mode: Short label (3-5 words) for the overall batch — e.g. 'Auth System Exploration'"),
|
|
561
579
|
content: tool.schema.array(tool.schema.object({
|
|
@@ -574,15 +592,15 @@ export const OhcPlugin = async (ctx) => {
|
|
|
574
592
|
const result = await executeRangeCompress(ctx, sessionId, callId, args.topic || "Compression", args.content)
|
|
575
593
|
toolCtx.metadata({ title: "Compress Range" })
|
|
576
594
|
const resultSs = getOrCreateState(sessionId)
|
|
577
|
-
await sendCompressNotification(ctx.client, sessionId, config, result.messageIds.length, result.summaryRef, result.compressedTokens, resultSs
|
|
578
|
-
return `Compressed ${result.messageIds.length}
|
|
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)}"`
|
|
579
597
|
}
|
|
580
598
|
|
|
581
599
|
const result = await applyCompress(ctx, sessionId, args.summary, max, min, args.targetTokens)
|
|
582
600
|
toolCtx.metadata({ title: "Compress" })
|
|
583
601
|
const toolSs = getOrCreateState(sessionId)
|
|
584
|
-
await sendCompressNotification(ctx.client, sessionId, config, result.removed, truncateText(args.summary, 200), result.tokensRemoved, toolSs
|
|
585
|
-
return `
|
|
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)}"`
|
|
586
604
|
},
|
|
587
605
|
}),
|
|
588
606
|
},
|
package/lib/ohc/reaper.mjs
CHANGED
|
@@ -7,10 +7,10 @@ function partTokens(part) {
|
|
|
7
7
|
t += (typeof part.state.output === "string" ? part.state.output : JSON.stringify(part.state.output ?? "")).length / 4
|
|
8
8
|
return Math.ceil(t)
|
|
9
9
|
}
|
|
10
|
-
return Math.ceil(JSON.stringify(part).length / 4)
|
|
10
|
+
try { return Math.ceil(JSON.stringify(part).length / 4) } catch { return 0 }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function msgTokens(msg) {
|
|
13
|
+
export function msgTokens(msg) {
|
|
14
14
|
return (Array.isArray(msg.parts) ? msg.parts : []).reduce((s, p) => s + partTokens(p), 0)
|
|
15
15
|
}
|
|
16
16
|
|
package/lib/ohc/state.mjs
CHANGED
|
@@ -89,6 +89,7 @@ export function createSessionState() {
|
|
|
89
89
|
summary: null,
|
|
90
90
|
anchorMessageId: null,
|
|
91
91
|
totalTokensSaved: 0,
|
|
92
|
+
totalMessagesRemoved: 0,
|
|
92
93
|
blockCount: 0,
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -136,6 +137,7 @@ export function serializeState(state) {
|
|
|
136
137
|
},
|
|
137
138
|
lastAutoPruneAt: state.lastAutoPruneAt,
|
|
138
139
|
totalTokensSaved: state.totalTokensSaved,
|
|
140
|
+
totalMessagesRemoved: state.totalMessagesRemoved,
|
|
139
141
|
blockCount: state.blockCount,
|
|
140
142
|
summary: state.summary,
|
|
141
143
|
anchorMessageId: state.anchorMessageId,
|
|
@@ -148,6 +150,7 @@ export function serializeState(state) {
|
|
|
148
150
|
},
|
|
149
151
|
compressionTiming: {
|
|
150
152
|
starts: Object.fromEntries(state.compressionTiming?.starts || new Map()),
|
|
153
|
+
pendingByCallId: pruneMapToObj(state.compressionTiming?.pendingByCallId || new Map()),
|
|
151
154
|
lastDurationMs: state.compressionTiming?.lastDurationMs || 0,
|
|
152
155
|
totalDurationMs: state.compressionTiming?.totalDurationMs || 0,
|
|
153
156
|
},
|
|
@@ -180,6 +183,7 @@ export function deserializeState(saved) {
|
|
|
180
183
|
}
|
|
181
184
|
state.lastAutoPruneAt = saved.lastAutoPruneAt || null
|
|
182
185
|
state.totalTokensSaved = saved.totalTokensSaved || 0
|
|
186
|
+
state.totalMessagesRemoved = saved.totalMessagesRemoved || 0
|
|
183
187
|
state.blockCount = saved.blockCount || 0
|
|
184
188
|
state.summary = saved.summary || null
|
|
185
189
|
state.anchorMessageId = saved.anchorMessageId || null
|
|
@@ -213,12 +217,10 @@ export function buildToolIdList(state, messages) {
|
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
export function syncToolCache(state, messages) {
|
|
216
|
-
let
|
|
220
|
+
let userTurn = 0
|
|
217
221
|
for (const msg of messages) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (lastUser) maxTurn++
|
|
221
|
-
}
|
|
222
|
+
const hasUserText = msg.info?.role === "user" && msg.parts?.some(p => p.type === "text" && p.text?.trim())
|
|
223
|
+
if (hasUserText) userTurn++
|
|
222
224
|
if (!Array.isArray(msg.parts)) continue
|
|
223
225
|
for (const part of msg.parts) {
|
|
224
226
|
if (part.type !== "tool" || !part.callID) continue
|
|
@@ -231,13 +233,13 @@ export function syncToolCache(state, messages) {
|
|
|
231
233
|
tool: part.tool || "unknown",
|
|
232
234
|
parameters: part.state?.input || {},
|
|
233
235
|
status: part.state?.status || "pending",
|
|
234
|
-
turn:
|
|
236
|
+
turn: userTurn,
|
|
235
237
|
tokenCount: estimateToolTokens(part),
|
|
236
238
|
lastSeen: Date.now(),
|
|
237
239
|
})
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
|
-
state.currentTurn = Math.max(state.currentTurn,
|
|
242
|
+
state.currentTurn = Math.max(state.currentTurn, userTurn)
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
function estimateToolTokens(part) {
|
package/lib/ohc/updater.mjs
CHANGED
|
@@ -87,38 +87,37 @@ async function handleUpdateMe(ctx, input, output) {
|
|
|
87
87
|
output.parts.length = 0
|
|
88
88
|
output.parts.push({
|
|
89
89
|
type: "text",
|
|
90
|
-
text: `[Update-Me] No OpenHermes cache found
|
|
91
|
-
Restart OpenCode to redownload from ${info.method === "git" ? "git HEAD" : "npm registry"}.`,
|
|
90
|
+
text: `[Update-Me] No OpenHermes plugin cache found.\nRestart OpenCode to redownload from ${info.method === "git" ? "git HEAD" : "npm registry"}.`,
|
|
92
91
|
})
|
|
93
92
|
return
|
|
94
93
|
}
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
let clearedCount = 0
|
|
96
|
+
let failedCount = 0
|
|
97
|
+
let firstError = null
|
|
98
98
|
for (const dir of cacheDirs) {
|
|
99
99
|
try {
|
|
100
100
|
fs.rmSync(dir.path, { recursive: true, force: true })
|
|
101
|
-
|
|
101
|
+
clearedCount++
|
|
102
102
|
} catch (e) {
|
|
103
|
-
|
|
103
|
+
failedCount++
|
|
104
|
+
if (!firstError) firstError = e.message
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
output.parts.length = 0
|
|
108
109
|
let msg = `[Update-Me] OpenHermes update (${info.method})\n`
|
|
109
110
|
|
|
110
|
-
if (
|
|
111
|
-
msg += `\
|
|
112
|
-
for (const d of deleted) msg += ` ✓ ${d}\n`
|
|
111
|
+
if (clearedCount > 0) {
|
|
112
|
+
msg += `\n ✓ Cleared OpenHermes plugin cache (${clearedCount} entr${clearedCount === 1 ? "y" : "ies"}).`
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
if (
|
|
116
|
-
msg += `\n⚠
|
|
117
|
-
|
|
118
|
-
msg += `Try deleting manually:\n ${CACHE_ROOTS.join("\n ")}\n`
|
|
115
|
+
if (failedCount > 0) {
|
|
116
|
+
msg += `\n ⚠ ${failedCount} entr${failedCount === 1 ? "y" : "ies"} could not be removed (may be locked).`
|
|
117
|
+
if (firstError) msg += `\n ${firstError}`
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
msg += `\nRestart OpenCode to load the latest OpenHermes from ${info.method === "git" ? "git HEAD" : "npm registry"}.`
|
|
120
|
+
msg += `\n\nRestart OpenCode to load the latest OpenHermes from ${info.method === "git" ? "git HEAD" : "npm registry"}.`
|
|
122
121
|
|
|
123
122
|
output.parts.push({ type: "text", text: msg })
|
|
124
123
|
}
|
package/lib/paths.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path"
|
|
|
2
2
|
import os from "node:os"
|
|
3
3
|
import fs from "node:fs"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { isTruthy } from "./hardening.mjs"
|
|
5
6
|
|
|
6
7
|
const HOME = process.env.USERPROFILE || os.homedir()
|
|
7
8
|
const DATA_ROOT = path.join(HOME, ".local", "share", "opencode", "openhermes")
|
|
@@ -45,6 +46,4 @@ export function getSchemaRoot() {
|
|
|
45
46
|
return path.resolve(PKG_DIR, "..", "schemas")
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
return /^(1|true|yes|on)$/i.test(String(value || ""))
|
|
50
|
-
}
|
|
49
|
+
|
package/lib/schema-validator.mjs
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
import { isPlainObject } from "./hardening.mjs"
|
|
2
|
+
|
|
1
3
|
const SUPPORTED_SCHEMA_KEYS = new Set(["type", "const", "enum", "format", "minimum", "maximum", "required", "properties", "items"])
|
|
2
4
|
const SCHEMA_METADATA_KEYS = new Set(["$schema", "title", "description", "default"])
|
|
3
5
|
|
|
4
|
-
function isPlainObject(value) {
|
|
5
|
-
return !!value && typeof value === "object" && !Array.isArray(value)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
6
|
function matchesType(type, value) {
|
|
9
7
|
if (type === "null") return value === null
|
|
10
8
|
if (type === "array") return Array.isArray(value)
|
|
@@ -26,7 +24,7 @@ function validateNode(schema, value, at, errors) {
|
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
if (typeof value === "string" && schema.format === "date-time" && Number.isNaN(Date.parse(value))) errors.push(`${at} must be a valid date-time string`)
|
|
29
|
-
if (typeof value === "number"
|
|
27
|
+
if (typeof value === "number") {
|
|
30
28
|
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${at} must be >= ${schema.minimum}`)
|
|
31
29
|
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${at} must be <= ${schema.maximum}`)
|
|
32
30
|
}
|
|
@@ -64,6 +62,10 @@ function visitSchemaNode(schema, at, unsupported) {
|
|
|
64
62
|
visitSchemaNode(value, `${at}.items`, unsupported)
|
|
65
63
|
continue
|
|
66
64
|
}
|
|
65
|
+
if (key === "$defs" || key === "definitions") {
|
|
66
|
+
if (isPlainObject(value)) for (const defName of Object.keys(value)) visitSchemaNode(value[defName], `${at}.${key}.${defName}`, unsupported)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
67
69
|
if (!SUPPORTED_SCHEMA_KEYS.has(key) && !SCHEMA_METADATA_KEYS.has(key)) unsupported.push(`${at} uses unsupported schema keyword "${key}"`)
|
|
68
70
|
}
|
|
69
71
|
}
|
package/lib/search.mjs
CHANGED
|
@@ -3,7 +3,7 @@ export function scoreRelevance(r, query, project) {
|
|
|
3
3
|
const tokens = q.split(/\s+/).filter(t => t.length > 2)
|
|
4
4
|
let score = 0
|
|
5
5
|
|
|
6
|
-
const primaryFields = [r.summary, r.description, r.mission, r.current_state, r.failure, r.root_cause, r.fix, r.prevention, r.id].filter(Boolean)
|
|
6
|
+
const primaryFields = [r.summary, r.description, r.mission, r.current_state, r.failure, r.root_cause, r.fix, r.prevention, r.id, r.choice, r.reason, r.source, r.artifact, r.result].filter(Boolean)
|
|
7
7
|
const secondaryFields = [r.command, r.project, r.scope].filter(Boolean)
|
|
8
8
|
const listFields = [...(Array.isArray(r.tags) ? r.tags : []), ...(Array.isArray(r.next_actions) ? r.next_actions : []), ...(Array.isArray(r.refs) ? r.refs : [])].filter(Boolean)
|
|
9
9
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openhermes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "OpenHermes plugin suite for OpenCode — autonomous checkpointing, native memory tools, subagent routing, slash commands, and skill-candidate detection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"archived_at": { "type": ["string", "null"], "format": "date-time", "description": "ISO-8601 or null" },
|
|
38
38
|
"title": { "type": "string", "description": "Human-readable title for this backlog item" },
|
|
39
39
|
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical", "P0", "P1", "P2", "P3", "P4"], "description": "Priority level (low/medium/high/critical or P0-P4)" },
|
|
40
|
-
"trigger": { "type": "string", "enum": ["audit", "mistake", "drift", "user", "manual"], "description": "What triggered creation of this item" },
|
|
40
|
+
"trigger": { "type": "string", "enum": ["audit", "mistake", "drift", "user", "manual"], "default": "manual", "description": "What triggered creation of this item" },
|
|
41
41
|
"evidence_refs": { "type": "array", "items": { "type": "string" }, "description": "References to evidence (audit IDs, mistake IDs, file paths)" },
|
|
42
42
|
"done_when": { "type": "array", "items": { "type": "string" }, "description": "Acceptance criteria — concrete conditions for closure" },
|
|
43
43
|
"description": { "type": "string", "description": "Optional description" },
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"review_at": { "type": ["string", "null"], "format": "date-time", "description": "ISO-8601 or null" },
|
|
36
36
|
"decay_at": { "type": ["string", "null"], "format": "date-time", "description": "ISO-8601 or null" },
|
|
37
37
|
"archived_at": { "type": ["string", "null"], "format": "date-time", "description": "ISO-8601 or null" },
|
|
38
|
-
"trigger": { "type": "string", "description": "Condition or pattern that triggers this instinct" },
|
|
38
|
+
"trigger": { "type": "string", "default": "manual", "description": "Condition or pattern that triggers this instinct" },
|
|
39
39
|
"action": { "type": "string", "description": "Recommended action when triggered" },
|
|
40
40
|
"success_count": { "type": "integer", "minimum": 0, "default": 0, "description": "Number of successful applications" },
|
|
41
41
|
"failure_count": { "type": "integer", "minimum": 0, "default": 0, "description": "Number of failed applications" },
|
|
42
|
-
"promotion_state": { "type": "string", "enum": ["project", "candidate_global", "global"], "description": "Current promotion level" },
|
|
42
|
+
"promotion_state": { "type": "string", "enum": ["project", "candidate_global", "global"], "default": "project", "description": "Current promotion level" },
|
|
43
43
|
"description": { "type": "string", "description": "Optional description" },
|
|
44
44
|
"environment_fingerprint": {
|
|
45
45
|
"type": "object",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"id": { "type": "string", "description": "Unique identifier, recommended pattern: mistake-YYYYMMDD-short-slug" },
|
|
9
9
|
"class": { "type": "string", "const": "mistake" },
|
|
10
10
|
"project": { "type": ["string", "null"], "description": "Project identifier; null for global/openhermes scope" },
|
|
11
|
-
"scope": { "type": "string", "enum": ["project", "global", "harness"], "description": "Scope of applicability" },
|
|
11
|
+
"scope": { "type": "string", "enum": ["project", "global", "session", "harness"], "description": "Scope of applicability" },
|
|
12
12
|
"summary": { "type": "string", "description": "One-line description of the mistake" },
|
|
13
13
|
"tags": { "type": "array", "items": { "type": "string" }, "description": "Searchable tags" },
|
|
14
14
|
"source": { "type": "string", "enum": ["user", "agent", "audit", "migration", "repair"], "description": "Origin of this record" },
|