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 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
 
@@ -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.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) {
@@ -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,
@@ -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,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 buildProgressBar(prunedCount, visibleCount, width) {
7
- width = width || 30
8
- const total = prunedCount + visibleCount
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 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, prunedCount, visibleCount, summary) {
23
- const label = "Compression"
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 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
- export async function sendCompressNotification(client, sessionId, config, count, summary, tokensRemoved, savedTotal, blockCount, prunedCount, visibleCount) {
29
+ export async function sendCompressNotification(client, sessionId, config, count, summary, tokensRemoved, ss, currentMessageCount) {
39
30
  if (count === 0) return false
40
31
 
41
- const notifType = config.notification ?? "toast"
42
- const notifMode = config.notificationMode ?? "minimal"
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
- ? buildMinimal(count, tokensRemoved, savedTotal, blockCount)
49
- : buildDetailed(count, tokensRemoved, savedTotal, blockCount, prunedCount, visibleCount, summary)
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: Compression",
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: buildDetailed(count, tokensRemoved, savedTotal, blockCount, prunedCount, visibleCount, summary), ignored: true }],
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 ?? "toast"
74
+ const notifType = config.notification ?? "chat"
78
75
  if (notifType === "off") return false
79
76
 
80
- const message = buildStrategyNotification(strategy, count, detail)
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: `OHC: ${strategy}`,
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, ignored: true }],
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 buildHeader = (action, summary) => `[Memory] ${summary}`
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: buildDetailed(action, cls, id, summary), ignored: true }],
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(/\*/g, ".*")
37
+ .replace(/\*\*/g, ".*")
38
+ .replace(/\*/g, "[^/]*")
38
39
  .replace(/\?/g, ".")
39
40
  try {
40
41
  return new RegExp(`^${regexStr}$`, "i").test(filePath)
@@ -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 `[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) {
@@ -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:") || p.text?.startsWith("[Auto-pruned:")) {
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 += summaryTokens
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: plans.flatMap(p => p.selection.messageIds),
175
- compressedTokens: 0,
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 `, last compress ${lastSec}s, total ${totalSec}s`
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
- const summaryBufNote = config.compress?.summaryBuffer ? ` Summary messages extend the budget.` : ``
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 = `[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\`.`
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 ? ` Blocks: bk${activeBlockIds.join(", bk")}.` : ""
419
- const text = `[OHC Status] ${msgs.length} messages visible (${prunedCount} auto-pruned, ${strategyPruned} strategy-pruned)${blockLine}${timing}. ~${Math.round(t / 1000)}K / ${max.toLocaleString()} tokens (${Math.round((t / max) * 100)}%). Soft floor: ${min.toLocaleString()}.`
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 ? ` Active: bk${activeBlockIds.join(", bk")}.` : ""
436
- 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}`
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: "[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 }] },
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: "[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 }] },
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?.totalTokensSaved || 0, cmdSs?.blockCount || 0, result.removed, result.afterCount)
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: `[OHC] Compressed: ${result.removed} messages removed. Summary: ${focus}`,
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 ? ` Active blocks: bk${activeBlockIds.join(", bk")}.` : ""
517
- 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")}`
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 = `[OHC] Swept: ${sweptCount} tool calls pruned.`
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 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]"
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. 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.",
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?.totalTokensSaved || 0, resultSs?.blockCount || 0, result.messageIds.length, 0)
578
- return `Compressed ${result.messageIds.length} messages across ${args.content.length} range(s). Summary: "${truncateText(result.summaryRef, 200)}"`
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?.totalTokensSaved || 0, toolSs?.blockCount || 0, result.removed, result.afterCount)
585
- return `Compressed: ${result.removed} messages removed. Summary: "${truncateText(args.summary, 200)}"`
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
  },
@@ -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 maxTurn = 0
220
+ let userTurn = 0
217
221
  for (const msg of messages) {
218
- if (msg.info?.role === "user") {
219
- const lastUser = state.toolIdList.length > 0
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: maxTurn,
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, maxTurn)
242
+ state.currentTurn = Math.max(state.currentTurn, userTurn)
241
243
  }
242
244
 
243
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.0",
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" },