openhermes 2.6.1 → 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 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 — New in v2.5
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
 
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.summary || "").includes("skill-candidate")
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
- const HARNESS_DIR = resolveHarnessRoot()
71
- const RULES_DIR = path.join(HARNESS_DIR, "rules")
72
- const SKILLS_DIR = path.join(HARNESS_DIR, "skills")
73
- const CONSTITUTION_FILE = path.join(HARNESS_DIR, "codex", "CONSTITUTION.md")
74
- const RUNTIME_FILE = path.join(HARNESS_DIR, "instructions", "RUNTIME.md")
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(CONSTITUTION_FILE, "utf8")
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(RUNTIME_FILE, "utf8")
136
- const capMap = buildCapabilityMap(HARNESS_DIR)
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 → \`${HARNESS_DIR}\\\`.
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%\\\\.config\\\\opencode\` is a git repo. Verify or roll back. **NEVER delete \`auth.json\`** (\`%USERPROFILE%\\\\.local\\\\share\\\\opencode\\\\auth.json\`).
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: \`${RULES_DIR}\\\delegation.md\`.
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 \`${RULES_DIR}\\\handoff.md\`.
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: \`${RULES_DIR}\\\\retrieval.md\`, \`${RULES_DIR}\\\\memory-management.md\`.
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: \`${RULES_DIR}\\\\self-heal.md\`.
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 (\`${HARNESS_DIR}\\\\codex\\\`). 4. Project constraints. 5. Project decisions. 6. Verified guards. 7. Checkpoints. 8. Instincts. 9. Freeform notes. Full: \`${RULES_DIR}\\\\precedence.md\`.
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 \`${RULES_DIR}\\\\skills-management.md\`.
221
- - Audit triggers: openhermes/config change, repeated failures, session start when last audit >7 days. See \`${RULES_DIR}\\\\audit.md\`.
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: \`${RULES_DIR}\\\\self-heal.md\`.
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%\\\\.config\\\\opencode\`
230
- - **Auth**: \`%USERPROFILE%\\\\.local\\\\share\\\\opencode\\\\auth.json\` (NEVER delete)
231
- - **Forensic ledger**: \`%USERPROFILE%\\\\.local\\\\share\\\\opencode\\\\opencode.db\``
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: \`${HARNESS_DIR}\\\`. Memory at \`~/.local/share/opencode/openhermes/memory/\`. Rules at \`${RULES_DIR}\\\`. Skills discoverable via \`skill\` tool — use \`skill\` tool to list/load them.`,
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
- function getOwnVersion() {
246
+ const OWN_VERSION = (() => {
242
247
  try {
243
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
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(SKILLS_DIR)) {
267
- config.skills.paths.push(SKILLS_DIR)
271
+ if (!config.skills.paths.includes(getSkillsDir())) {
272
+ config.skills.paths.push(getSkillsDir())
268
273
  }
269
274
 
270
- const PROMPTS_DIR = path.join(HARNESS_DIR, "prompts")
271
- const p = (name) => `{file:${path.join(PROMPTS_DIR, name)}}`
272
- const COMMANDS_DIR = path.join(HARNESS_DIR, "commands")
273
- const ct = (file) => `{file:${path.join(COMMANDS_DIR, file)}}\n\n$ARGUMENTS`
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 found for class "${record.class}", fallback check`)
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: provenance.session_id required`)
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] schema validation failed: unsupported keywords ${unsupported.join(", ")}`)
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] schema validation failed: ${errors.join("; ")}`)
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": "C:/Users/nathan/.config/opencode",
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 [bootstrap, autorecall, curator, skillBuilder, memoryTools, ambient, ohc, updater] = results.map(r => r.status === 'fulfilled' ? r.value : {})
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
- return fs.readFileSync(fp, "utf8").trim().split("\n").filter(Boolean).map(l => JSON.parse(l))
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
- export { atomicWriteJson, buildEnvironmentFingerprint, fingerprintEnvironment, fingerprintFile, isPlainObject, isTruthy, readJson, readJsonl, redactSensitiveText, sanitizeRecord, truncateText }
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
- fs.writeFileSync(fp, text ? `${text}\n` : "", "utf8")
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 || e
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 `[OHC: Compressed bk${blockId}]\n\n${summary}\n<ohc-ref>bk${blockId}</ohc-ref>`
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) {
@@ -16,8 +16,7 @@ const DEFAULTS = {
16
16
  turnProtection: { enabled: false, turns: 4 },
17
17
  protectedFilePatterns: [],
18
18
  compress: {
19
- maxContextLimit: 150000,
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
 
@@ -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
@@ -1,38 +1,29 @@
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 buildProgressBar(totalMessagesRemoved, currentMessageCount, width) {
7
- width = width || 30
8
- const total = totalMessagesRemoved + currentMessageCount
9
- if (total === 0) return `\u2502${"\u2591".repeat(width)}\u2502 0% active`
10
- const activeRatio = currentMessageCount / 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 buildMinimal(count, tokensRemoved, savedTotal, blockCount) {
18
- const label = "Compression"
19
- return `\u25A3 OHC | ~${formatTokenCount(savedTotal)} saved total \u2014 ${label}`
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 buildDetailed(count, tokensRemoved, savedTotal, blockCount, totalMessagesRemoved, currentMessageCount, summary) {
23
- const label = "Compression"
24
- let msg = `\u25A3 OHC | ~${formatTokenCount(savedTotal)} saved total`
25
- if (totalMessagesRemoved + currentMessageCount > 0) {
26
- msg += `\n\n${buildProgressBar(totalMessagesRemoved, currentMessageCount)}`
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 buildStrategyNotification(strategy, count, detail) {
35
- return `\u25A3 OHC | ${strategy}: ${count} pruned${detail ? ` (${detail})` : ""}`
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
29
  export async function sendCompressNotification(client, sessionId, config, count, summary, tokensRemoved, ss, currentMessageCount) {
@@ -40,21 +31,20 @@ export async function sendCompressNotification(client, sessionId, config, count,
40
31
 
41
32
  const savedTotal = ss?.totalTokensSaved || 0
42
33
  const blockCount = ss?.blockCount || 0
43
- const totalMessagesRemoved = ss?.totalMessagesRemoved || 0
44
34
 
45
- const notifType = config.notification ?? "toast"
46
- const notifMode = config.notificationMode ?? "minimal"
35
+ const notifType = config.notification ?? "chat"
36
+ const notifMode = config.notificationMode ?? "detailed"
47
37
 
48
38
  if (notifType === "off") return false
49
39
 
50
40
  if (notifType === "toast") {
51
41
  const message = notifMode === "minimal"
52
- ? buildMinimal(count, tokensRemoved, savedTotal, blockCount)
53
- : buildDetailed(count, tokensRemoved, savedTotal, blockCount, totalMessagesRemoved, currentMessageCount, summary)
42
+ ? buildCompressionMinimal(count, savedTotal, blockCount)
43
+ : buildCompressionDetailed(count, savedTotal, blockCount, summary)
54
44
  try {
55
45
  await client.tui.showToast({
56
46
  body: {
57
- title: "OHC: Compression",
47
+ title: "OHC Compression",
58
48
  message,
59
49
  variant: "info",
60
50
  duration: 5000,
@@ -64,12 +54,15 @@ export async function sendCompressNotification(client, sessionId, config, count,
64
54
  return true
65
55
  }
66
56
 
57
+ const message = notifMode === "minimal"
58
+ ? buildCompressionMinimal(count, savedTotal, blockCount)
59
+ : buildCompressionDetailed(count, savedTotal, blockCount, summary)
67
60
  try {
68
61
  await client.session.prompt({
69
62
  path: { id: sessionId },
70
63
  body: {
71
64
  noReply: true,
72
- parts: [{ type: "text", text: buildDetailed(count, tokensRemoved, savedTotal, blockCount, totalMessagesRemoved, currentMessageCount, summary), ignored: true }],
65
+ parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
73
66
  },
74
67
  })
75
68
  } catch {}
@@ -78,16 +71,16 @@ export async function sendCompressNotification(client, sessionId, config, count,
78
71
 
79
72
  export async function sendStrategyNotification(client, sessionId, config, strategy, count, detail) {
80
73
  if (count === 0) return false
81
- const notifType = config.notification ?? "toast"
74
+ const notifType = config.notification ?? "chat"
82
75
  if (notifType === "off") return false
83
76
 
84
- const message = buildStrategyNotification(strategy, count, detail)
77
+ const message = detail ? `${strategy} \u2014 ${count} pruned \u00b7 ${truncate(detail, 36)}` : `${strategy} \u2014 ${count} pruned`
85
78
 
86
79
  if (notifType === "toast") {
87
80
  try {
88
81
  await client.tui.showToast({
89
82
  body: {
90
- title: `OHC: ${strategy}`,
83
+ title: "OHC Strategy",
91
84
  message,
92
85
  variant: "info",
93
86
  duration: 3000,
@@ -102,7 +95,7 @@ export async function sendStrategyNotification(client, sessionId, config, strate
102
95
  path: { id: sessionId },
103
96
  body: {
104
97
  noReply: true,
105
- parts: [{ type: "text", text: message, ignored: true }],
98
+ parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
106
99
  },
107
100
  })
108
101
  } catch {}
@@ -111,27 +104,15 @@ export async function sendStrategyNotification(client, sessionId, config, strate
111
104
 
112
105
  export async function sendMemoryNotification(client, sessionId, config, action, cls, id, summary) {
113
106
  const notifType = config.notification ?? "toast"
114
- const notifMode = config.notificationMode ?? "minimal"
115
-
116
107
  if (notifType === "off") return false
117
108
 
118
- const buildHeader = (action, summary) => `[Memory] ${summary}`
119
- const buildDetailed = (action, cls, id, summary) => {
120
- let msg = `\u25A3 Memory ${action}`
121
- if (cls) msg += `\n\u2192 Class: ${cls}`
122
- if (id) msg += `\n\u2192 ID: ${id}`
123
- if (summary) msg += `\n\u2192 ${summary}`
124
- return msg
125
- }
109
+ const message = buildMemoryMessage(action, cls, id, summary)
126
110
 
127
111
  if (notifType === "toast") {
128
- const message = notifMode === "minimal"
129
- ? buildHeader(action, summary)
130
- : buildDetailed(action, cls, id, summary)
131
112
  try {
132
113
  await client.tui.showToast({
133
114
  body: {
134
- title: "Memory",
115
+ title: "OHC Memory",
135
116
  message,
136
117
  variant: "info",
137
118
  duration: 4000,
@@ -146,7 +127,7 @@ export async function sendMemoryNotification(client, sessionId, config, action,
146
127
  path: { id: sessionId },
147
128
  body: {
148
129
  noReply: true,
149
- parts: [{ type: "text", text: buildDetailed(action, cls, id, summary), ignored: true }],
130
+ parts: [{ type: "text", text: `OHC: ${message}`, ignored: true }],
150
131
  },
151
132
  })
152
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(/\*/g, ".*")
37
+ .replace(/\*\*/g, ".*")
38
+ .replace(/\*/g, "[^/]*")
38
39
  .replace(/\?/g, ".")
39
40
  try {
40
41
  return new RegExp(`^${regexStr}$`, "i").test(filePath)
@@ -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 `[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.`
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 `[Compressed: ${summary} ${n} message${n === 1 ? "" : "s"} removed]`
50
- return `[Auto-pruned: ${n} message${n === 1 ? "" : "s"} removed]`
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) {
@@ -99,7 +99,7 @@ function estimateSummaryTokens(messages) {
99
99
  for (const m of messages) {
100
100
  if (m.info?.role === "system" && Array.isArray(m.parts)) {
101
101
  for (const p of m.parts) {
102
- if (p.type === "text" && p.text?.startsWith("[Compressed:") || p.text?.startsWith("[Auto-pruned:")) {
102
+ if (p.type === "text" && (p.text?.startsWith("[OHC: Compressed]") || p.text?.startsWith("[OHC: Auto-pruned]"))) {
103
103
  total += Math.ceil(p.text.length / 4)
104
104
  }
105
105
  }
@@ -209,7 +209,7 @@ export const OhcPlugin = async (ctx) => {
209
209
  const total = ss.compressionTiming.totalDurationMs || 0
210
210
  const lastSec = (last / 1000).toFixed(1)
211
211
  const totalSec = (total / 1000).toFixed(1)
212
- return `, last compress ${lastSec}s, total ${totalSec}s`
212
+ return ` (last: ${lastSec}s, total: ${totalSec}s)`
213
213
  }
214
214
 
215
215
  function computeRoleBreakdown(messages) {
@@ -248,11 +248,7 @@ export const OhcPlugin = async (ctx) => {
248
248
  "experimental.chat.system.transform": async (_input, output) => {
249
249
  if (systemInjected || !output.system?.length) return
250
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`
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`
256
252
  },
257
253
 
258
254
  "experimental.chat.messages.transform": async (_input, output) => {
@@ -276,6 +272,7 @@ export const OhcPlugin = async (ctx) => {
276
272
  deduplicate(ss, config, output.messages)
277
273
  purgeErrors(ss, config, output.messages)
278
274
  applyPruneTools(ss, output.messages)
275
+ applyFullToolRemoval(ss, output.messages)
279
276
 
280
277
  const now = Date.now()
281
278
  const recentlyPruned = ss.lastAutoPruneAt && (now - ss.lastAutoPruneAt) < AUTOPRUNE_COOLDOWN
@@ -283,6 +280,7 @@ export const OhcPlugin = async (ctx) => {
283
280
  if (ss.prunedIds.size > 0 && !recentlyPruned) {
284
281
  const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
285
282
  if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
283
+ cleanupMessageRefs(ss, ss.prunedIds)
286
284
  ss.prunedIds.clear()
287
285
  ss.summary = null
288
286
  ss.anchorMessageId = null
@@ -358,7 +356,7 @@ export const OhcPlugin = async (ctx) => {
358
356
  const iterationThreshold = config.compress?.iterationNudgeThreshold ?? 15
359
357
  const iterCount = countIterationsSinceLastUser(output.messages)
360
358
  if (iterCount >= iterationThreshold && iterCount % 5 === 0) {
361
- const iterNudge = `[OHC] ${iterCount} AI iterations since your last message. Consider summarizing completed work with \`compress\`.`
359
+ const iterNudge = `OHC: ${iterCount} AI turns since your last input. Summarize with \`compress\`.`
362
360
  if (nudgeText) nudgeText += "\n\n" + iterNudge
363
361
  else nudgeText = iterNudge
364
362
  }
@@ -431,12 +429,12 @@ export const OhcPlugin = async (ctx) => {
431
429
  const strategyPruned = ss?.prune?.tools?.size || 0
432
430
  const timing = buildTimingStr(ss)
433
431
  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")}.` : ""
432
+ const blockLine = activeBlockIds.length ? ` bk${activeBlockIds.join(", bk")}.` : ""
435
433
  const summaryBufferTotal = config.compress?.summaryBuffer
436
434
  ? estimateSummaryTokens(msgs)
437
435
  : 0
438
436
  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()}.`
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}`
440
438
  await ctx.client.session.prompt({
441
439
  path: { id: input.sessionID },
442
440
  body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
@@ -452,8 +450,8 @@ export const OhcPlugin = async (ctx) => {
452
450
  const autoPruned = ss?.prunedIds?.size || 0
453
451
  const timing = buildTimingStr(ss)
454
452
  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.`
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}`
457
455
  await ctx.client.session.prompt({
458
456
  path: { id: input.sessionID },
459
457
  body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
@@ -468,14 +466,14 @@ export const OhcPlugin = async (ctx) => {
468
466
  if (ss) ss.manualMode = "active"
469
467
  await ctx.client.session.prompt({
470
468
  path: { id: input.sessionID },
471
- body: { noReply: true, parts: [{ type: "text", text: "[OHC] Manual mode enabled. Agent will not autonomously compress.", ignored: true }] },
469
+ body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: active \u2014 agent will not autonomously compress", ignored: true }] },
472
470
  })
473
471
  } else {
474
472
  const ss = getOrCreateState(input.sessionID)
475
473
  if (ss) ss.manualMode = false
476
474
  await ctx.client.session.prompt({
477
475
  path: { id: input.sessionID },
478
- body: { noReply: true, parts: [{ type: "text", text: "[OHC] Manual mode disabled. Agent can compress autonomously.", ignored: true }] },
476
+ body: { noReply: true, parts: [{ type: "text", text: "OHC Manual: off \u2014 agent can compress autonomously", ignored: true }] },
479
477
  })
480
478
  }
481
479
  throw new Error("__OHC_MANUAL_HANDLED__")
@@ -499,7 +497,7 @@ export const OhcPlugin = async (ctx) => {
499
497
  output.parts.length = 0
500
498
  output.parts.push({
501
499
  type: "text",
502
- text: `[OHC] Compressed: ${result.removed} messages removed. Summary: ${focus}`,
500
+ text: `OHC: Compressed ${result.removed} msgs. Summary: ${focus}`,
503
501
  })
504
502
  } catch {
505
503
  output.parts.length = 0
@@ -533,8 +531,8 @@ export const OhcPlugin = async (ctx) => {
533
531
  }
534
532
 
535
533
  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")}`
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")}`
538
536
  await ctx.client.session.prompt({
539
537
  path: { id: input.sessionID },
540
538
  body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
@@ -559,13 +557,13 @@ export const OhcPlugin = async (ctx) => {
559
557
  }
560
558
  }
561
559
  applyPruneTools(ss, output.messages)
562
- const text = `[OHC] Swept: ${sweptCount} tool calls pruned.`
560
+ const text = `OHC Sweep: ${sweptCount} tool call${sweptCount === 1 ? "" : "s"} pruned`
563
561
  output.parts.length = 0
564
562
  output.parts.push({ type: "text", text })
565
563
  return
566
564
  }
567
565
 
568
- const text = "OHC commands: /ohc status /ohc stats /ohc context /ohc sweep [n] /ohc manual [on|off] /ohc compress [focus]"
566
+ const text = "OHC: /ohc status | stats | context | sweep [n] | manual [on|off] | compress [focus]"
569
567
  await ctx.client.session.prompt({
570
568
  path: { id: input.sessionID },
571
569
  body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
@@ -575,7 +573,7 @@ export const OhcPlugin = async (ctx) => {
575
573
 
576
574
  tool: {
577
575
  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.",
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.",
579
577
  args: {
580
578
  topic: tool.schema.string().optional().describe("Range mode: Short label (3-5 words) for the overall batch — e.g. 'Auth System Exploration'"),
581
579
  content: tool.schema.array(tool.schema.object({
@@ -595,14 +593,14 @@ export const OhcPlugin = async (ctx) => {
595
593
  toolCtx.metadata({ title: "Compress Range" })
596
594
  const resultSs = getOrCreateState(sessionId)
597
595
  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)}"`
596
+ return `OHC: Compressed ${result.messageIds.length} msgs across ${args.content.length} range(s). Summary: "${truncateText(result.summaryRef, 200)}"`
599
597
  }
600
598
 
601
599
  const result = await applyCompress(ctx, sessionId, args.summary, max, min, args.targetTokens)
602
600
  toolCtx.metadata({ title: "Compress" })
603
601
  const toolSs = getOrCreateState(sessionId)
604
602
  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)}"`
603
+ return `OHC: Compressed ${result.removed} msgs. Summary: "${truncateText(args.summary, 200)}"`
606
604
  },
607
605
  }),
608
606
  },
@@ -7,7 +7,7 @@ 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
13
  export function msgTokens(msg) {
package/lib/ohc/state.mjs CHANGED
@@ -150,6 +150,7 @@ export function serializeState(state) {
150
150
  },
151
151
  compressionTiming: {
152
152
  starts: Object.fromEntries(state.compressionTiming?.starts || new Map()),
153
+ pendingByCallId: pruneMapToObj(state.compressionTiming?.pendingByCallId || new Map()),
153
154
  lastDurationMs: state.compressionTiming?.lastDurationMs || 0,
154
155
  totalDurationMs: state.compressionTiming?.totalDurationMs || 0,
155
156
  },
@@ -216,12 +217,10 @@ export function buildToolIdList(state, messages) {
216
217
  }
217
218
 
218
219
  export function syncToolCache(state, messages) {
219
- let maxTurn = 0
220
+ let userTurn = 0
220
221
  for (const msg of messages) {
221
- if (msg.info?.role === "user") {
222
- const lastUser = state.toolIdList.length > 0
223
- if (lastUser) maxTurn++
224
- }
222
+ const hasUserText = msg.info?.role === "user" && msg.parts?.some(p => p.type === "text" && p.text?.trim())
223
+ if (hasUserText) userTurn++
225
224
  if (!Array.isArray(msg.parts)) continue
226
225
  for (const part of msg.parts) {
227
226
  if (part.type !== "tool" || !part.callID) continue
@@ -234,13 +233,13 @@ export function syncToolCache(state, messages) {
234
233
  tool: part.tool || "unknown",
235
234
  parameters: part.state?.input || {},
236
235
  status: part.state?.status || "pending",
237
- turn: maxTurn,
236
+ turn: userTurn,
238
237
  tokenCount: estimateToolTokens(part),
239
238
  lastSeen: Date.now(),
240
239
  })
241
240
  }
242
241
  }
243
- state.currentTurn = Math.max(state.currentTurn, maxTurn)
242
+ state.currentTurn = Math.max(state.currentTurn, userTurn)
244
243
  }
245
244
 
246
245
  function estimateToolTokens(part) {
@@ -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 under OpenCode package/node_modules caches.
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
- const deleted = []
97
- const failed = []
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
- deleted.push(dir.name)
101
+ clearedCount++
102
102
  } catch (e) {
103
- failed.push({ name: dir.name, error: e.message })
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 (deleted.length > 0) {
111
- msg += `\nCleared:\n`
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 (failed.length > 0) {
116
- msg += `\n⚠ Could not remove (file may be locked):\n`
117
- for (const f of failed) msg += ` ${f.name} — ${f.error}\n`
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
- function isTruthy(value) {
49
- return /^(1|true|yes|on)$/i.test(String(value || ""))
50
- }
49
+
@@ -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" || Number.isInteger(value)) {
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.6.1",
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" },