openhermes 1.5.2 → 1.12.1
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/LICENSE +21 -0
- package/README.md +256 -157
- package/autorecall.mjs +2 -12
- package/bootstrap.mjs +158 -8
- package/curator.mjs +1 -5
- package/harness/commands/checkpoint.md +68 -0
- package/harness/commands/eval.md +89 -0
- package/harness/commands/go-build.md +87 -0
- package/harness/commands/go-review.md +71 -0
- package/harness/commands/harness-audit.md +90 -0
- package/harness/commands/learn.md +2 -2
- package/harness/commands/loop-start.md +38 -0
- package/harness/commands/loop-status.md +30 -0
- package/harness/commands/memory-search.md +2 -2
- package/harness/commands/model-route.md +32 -0
- package/harness/commands/orchestrate.md +88 -0
- package/harness/commands/quality-gate.md +35 -0
- package/harness/commands/refactor-clean.md +102 -0
- package/harness/commands/rust-build.md +78 -0
- package/harness/commands/rust-review.md +65 -0
- package/harness/commands/setup-pm.md +65 -0
- package/harness/commands/skill-create.md +99 -0
- package/harness/commands/test-coverage.md +80 -0
- package/harness/commands/update-codemaps.md +81 -0
- package/harness/commands/update-docs.md +67 -0
- package/harness/commands/verify.md +68 -0
- package/harness/instructions/CONVENTIONS.md +206 -0
- package/harness/instructions/RUNTIME.md +8 -1
- package/harness/prompts/build-cpp.md +84 -0
- package/harness/prompts/build-error-resolver.md +2 -1
- package/harness/prompts/build-go.md +326 -0
- package/harness/prompts/build-java.md +126 -0
- package/harness/prompts/build-kotlin.md +123 -0
- package/harness/prompts/build-rust.md +94 -0
- package/harness/prompts/code-reviewer.md +2 -1
- package/harness/prompts/doc-updater.md +193 -0
- package/harness/prompts/docs-lookup.md +60 -0
- package/harness/prompts/explore.md +1 -0
- package/harness/prompts/harness-optimizer.md +30 -0
- package/harness/prompts/loop-operator.md +42 -0
- package/harness/prompts/planner.md +3 -2
- package/harness/prompts/refactor-cleaner.md +242 -0
- package/harness/prompts/review-cpp.md +68 -0
- package/harness/prompts/review-database.md +248 -0
- package/harness/prompts/review-go.md +244 -0
- package/harness/prompts/review-java.md +100 -0
- package/harness/prompts/review-kotlin.md +130 -0
- package/harness/prompts/review-python.md +88 -0
- package/harness/prompts/review-rust.md +64 -0
- package/harness/prompts/security-reviewer.md +3 -2
- package/harness/prompts/tdd-guide.md +214 -0
- package/harness/rules/delegation.md +28 -22
- package/harness/rules/memory-management.md +4 -4
- package/harness/rules/retrieval.md +5 -5
- package/harness/rules/runtime-guards.md +1 -1
- package/harness/rules/session-start.md +4 -4
- package/harness/rules/skills-management.md +2 -2
- package/harness/rules/state-drift.md +1 -1
- package/harness/rules/verification.md +4 -4
- package/harness/skills/coding-standards/SKILL.md +1 -1
- package/index.mjs +25 -4
- package/lib/hardening.mjs +11 -1
- package/lib/memory-tools-plugin.mjs +101 -54
- package/lib/ohc/config.mjs +30 -0
- package/lib/ohc/pruner.mjs +239 -0
- package/lib/ohc/reaper.mjs +61 -0
- package/lib/ohc/state.mjs +32 -0
- package/lib/ohc/updater.mjs +110 -0
- package/package.json +1 -1
- package/skill-builder.mjs +2 -6
- package/lib/tools/_memory.mjs +0 -230
- package/lib/tools/hm_get.mjs +0 -13
- package/lib/tools/hm_latest.mjs +0 -12
- package/lib/tools/hm_list.mjs +0 -13
- package/lib/tools/hm_put.mjs +0 -14
- package/lib/tools/hm_search.mjs +0 -16
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { loadConfig } from "./config.mjs"
|
|
3
|
+
import { selectMessagesToReap, totalTokens } from "./reaper.mjs"
|
|
4
|
+
import { loadOhcState, saveOhcState } from "./state.mjs"
|
|
5
|
+
|
|
6
|
+
function buildNudge(pct, max) {
|
|
7
|
+
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.`
|
|
8
|
+
if (pct > 0.85) return `[OHC] Context at ${Math.round(pct * 100)}%. Proactive compression recommended. Run \`compress\` to free space.`
|
|
9
|
+
if (pct > 0.70) return `[OHC] Context at ${Math.round(pct * 100)}% of budget. Consider using \`compress\` to keep room for new content.`
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarizeRemoved(selected, summary) {
|
|
14
|
+
const n = selected.length
|
|
15
|
+
if (summary) return `[Compressed: ${summary} — ${n} message${n === 1 ? "" : "s"} removed]`
|
|
16
|
+
return `[Auto-pruned: ${n} message${n === 1 ? "" : "s"} removed]`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createSummaryMessage(text) {
|
|
20
|
+
return { parts: [{ type: "text", text }], info: { role: "system" } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function applyCompress(ctx, sessionId, summary, max, min, targetTokens) {
|
|
24
|
+
const ss = getOrCreateState(sessionId)
|
|
25
|
+
ss.prunedIds.clear()
|
|
26
|
+
|
|
27
|
+
const res = await ctx.client.session.messages({ path: { id: sessionId } })
|
|
28
|
+
const msgs = res?.data || res || []
|
|
29
|
+
if (!Array.isArray(msgs)) return { removed: 0, message: "no messages" }
|
|
30
|
+
|
|
31
|
+
const selected = selectMessagesToReap(msgs, max, min, "compress", targetTokens)
|
|
32
|
+
if (selected.length === 0) return { removed: 0, message: "already within target" }
|
|
33
|
+
|
|
34
|
+
for (const r of selected) ss.prunedIds.add(r.id)
|
|
35
|
+
ss.summary = summarizeRemoved(selected, summary)
|
|
36
|
+
ss.anchorMessageId = selected[0].id
|
|
37
|
+
saveOhcState(sessionId, {
|
|
38
|
+
prunedMessageIds: [...ss.prunedIds],
|
|
39
|
+
summary: ss.summary,
|
|
40
|
+
anchorMessageId: ss.anchorMessageId,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { removed: selected.length }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stateCache = new Map()
|
|
47
|
+
|
|
48
|
+
function getOrCreateState(sessionId) {
|
|
49
|
+
if (!sessionId) return null
|
|
50
|
+
let s = stateCache.get(sessionId)
|
|
51
|
+
if (!s) {
|
|
52
|
+
const persisted = loadOhcState(sessionId)
|
|
53
|
+
s = {
|
|
54
|
+
prunedIds: new Set(persisted?.prunedMessageIds || []),
|
|
55
|
+
summary: persisted?.summary || null,
|
|
56
|
+
anchorMessageId: persisted?.anchorMessageId || null,
|
|
57
|
+
lastNudgePct: 0,
|
|
58
|
+
}
|
|
59
|
+
stateCache.set(sessionId, s)
|
|
60
|
+
}
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const OhcPlugin = async (ctx) => {
|
|
65
|
+
const config = loadConfig()
|
|
66
|
+
if (!config.enabled) return {}
|
|
67
|
+
|
|
68
|
+
const max = config.max
|
|
69
|
+
const min = config.min
|
|
70
|
+
if (max <= min + 10000) return {}
|
|
71
|
+
|
|
72
|
+
let systemInjected = false
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
76
|
+
if (systemInjected || !output.system?.length) return
|
|
77
|
+
systemInjected = true
|
|
78
|
+
output.system[output.system.length - 1] += `\n\n## Context Management (OHC)\n- OHC manages all compression. Set \`compaction.auto: false\` in opencode.json to prevent double pruning.\n- Default soft budget: ${max.toLocaleString()} tokens. Soft floor: ${min.toLocaleString()}.\n- These are advisory — the agent can override by passing \`targetTokens\` to the \`compress\` tool.\n- Call \`compress\` with a summary to free context space. Optionally specify \`targetTokens\` (lower = more aggressive).`
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
82
|
+
if (!output?.messages?.length) return
|
|
83
|
+
|
|
84
|
+
const sessionId = output.messages.find(m => m.info?.sessionID)?.info?.sessionID
|
|
85
|
+
if (!sessionId) return
|
|
86
|
+
|
|
87
|
+
const ss = getOrCreateState(sessionId)
|
|
88
|
+
if (!ss) return
|
|
89
|
+
|
|
90
|
+
if (ss.prunedIds.size > 0) {
|
|
91
|
+
const currentIds = new Set(output.messages.map(m => m.info?.id).filter(Boolean))
|
|
92
|
+
if ([...ss.prunedIds].every(id => !currentIds.has(id))) {
|
|
93
|
+
ss.prunedIds.clear()
|
|
94
|
+
ss.summary = null
|
|
95
|
+
ss.anchorMessageId = null
|
|
96
|
+
saveOhcState(sessionId, {
|
|
97
|
+
prunedMessageIds: [],
|
|
98
|
+
summary: null,
|
|
99
|
+
anchorMessageId: null,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const currentTotal = totalTokens(output.messages)
|
|
105
|
+
if (currentTotal > max) {
|
|
106
|
+
const selected = selectMessagesToReap(output.messages, max, min)
|
|
107
|
+
if (selected.length > 0) {
|
|
108
|
+
for (const r of selected) ss.prunedIds.add(r.id)
|
|
109
|
+
if (!ss.summary) ss.summary = summarizeRemoved(selected, null)
|
|
110
|
+
if (!ss.anchorMessageId) ss.anchorMessageId = selected[0].id
|
|
111
|
+
saveOhcState(sessionId, {
|
|
112
|
+
prunedMessageIds: [...ss.prunedIds],
|
|
113
|
+
summary: ss.summary,
|
|
114
|
+
anchorMessageId: ss.anchorMessageId,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ss.prunedIds.size > 0) {
|
|
120
|
+
const prunedIds = ss.prunedIds
|
|
121
|
+
const summary = ss.summary
|
|
122
|
+
const anchorId = ss.anchorMessageId
|
|
123
|
+
const result = []
|
|
124
|
+
let injected = false
|
|
125
|
+
|
|
126
|
+
for (const msg of output.messages) {
|
|
127
|
+
const msgId = msg.info?.id
|
|
128
|
+
|
|
129
|
+
if (anchorId && msgId === anchorId && !injected && summary) {
|
|
130
|
+
result.push(createSummaryMessage(summary))
|
|
131
|
+
injected = true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (msgId !== undefined && prunedIds.has(msgId)) continue
|
|
135
|
+
|
|
136
|
+
result.push(msg)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!injected && summary && result.length > 1) {
|
|
140
|
+
result.splice(1, 0, createSummaryMessage(summary))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
output.messages.length = 0
|
|
144
|
+
output.messages.push(...result)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const afterTotal = totalTokens(output.messages)
|
|
148
|
+
const pct = afterTotal / max
|
|
149
|
+
const nudge = buildNudge(pct, max)
|
|
150
|
+
if (nudge && pct > ss.lastNudgePct + 0.05) {
|
|
151
|
+
ss.lastNudgePct = pct
|
|
152
|
+
for (let i = output.messages.length - 1; i >= 0; i--) {
|
|
153
|
+
const m = output.messages[i]
|
|
154
|
+
if (m.info?.role === "assistant" && m.parts?.length) {
|
|
155
|
+
const textPart = m.parts.find(p => p.type === "text")
|
|
156
|
+
if (textPart) { textPart.text += "\n\n" + nudge; break }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
"command.execute.before": async (input, output) => {
|
|
163
|
+
if (input.command !== "ohc") return
|
|
164
|
+
const sub = (input.arguments || "").trim().toLowerCase()
|
|
165
|
+
const args = (input.arguments || "").trim()
|
|
166
|
+
|
|
167
|
+
if (sub === "status") {
|
|
168
|
+
let msgs = [], t = 0
|
|
169
|
+
try {
|
|
170
|
+
if (ctx?.client?.session?.messages) {
|
|
171
|
+
const res = await ctx.client.session.messages({ path: { id: input.sessionID } })
|
|
172
|
+
msgs = res?.data || res || []
|
|
173
|
+
t = totalTokens(msgs)
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
const ss = getOrCreateState(input.sessionID)
|
|
177
|
+
const prunedCount = ss?.prunedIds.size || 0
|
|
178
|
+
const text = `[OHC Status] ${msgs.length} messages visible (${prunedCount} pruned), ~${Math.round(t / 1000)}K / ${max.toLocaleString()} tokens (${Math.round((t / max) * 100)}%). Soft floor: ${min.toLocaleString()}.`
|
|
179
|
+
await ctx.client.session.prompt({
|
|
180
|
+
path: { id: input.sessionID },
|
|
181
|
+
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
182
|
+
})
|
|
183
|
+
throw new Error("__OHC_STATUS_HANDLED__")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (sub.startsWith("compress")) {
|
|
187
|
+
const rest = args.replace(/^compress\s*/i, "").trim()
|
|
188
|
+
const numMatch = rest.match(/^(\d+)\s*(.*)/)
|
|
189
|
+
let targetTokens
|
|
190
|
+
let focus
|
|
191
|
+
if (numMatch) {
|
|
192
|
+
targetTokens = parseInt(numMatch[1], 10)
|
|
193
|
+
focus = numMatch[2].trim() || "Manual compression by user"
|
|
194
|
+
} else {
|
|
195
|
+
focus = rest || "Manual compression by user"
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const result = await applyCompress(ctx, input.sessionID, focus, max, min, targetTokens)
|
|
199
|
+
output.parts.length = 0
|
|
200
|
+
output.parts.push({
|
|
201
|
+
type: "text",
|
|
202
|
+
text: `[OHC] Compressed: ${result.removed} messages removed. Summary: ${focus}`,
|
|
203
|
+
})
|
|
204
|
+
} catch {
|
|
205
|
+
output.parts.length = 0
|
|
206
|
+
output.parts.push({ type: "text", text: `/ohc compress ${focus}` })
|
|
207
|
+
}
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const text = "OHC commands: /ohc status — /ohc compress [targetTokens] [focus description]"
|
|
212
|
+
await ctx.client.session.prompt({
|
|
213
|
+
path: { id: input.sessionID },
|
|
214
|
+
body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
|
|
215
|
+
})
|
|
216
|
+
throw new Error("__OHC_HELP_HANDLED__")
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
tool: {
|
|
220
|
+
compress: tool({
|
|
221
|
+
description: "Proactively compress old conversation content to free context space. Provide a technical summary of what was removed. Optionally specify targetTokens to control how much to keep (lower = more aggressive).",
|
|
222
|
+
args: {
|
|
223
|
+
summary: tool.schema.string().describe("Technical summary of the compressed content. Include what was removed and key decisions preserved."),
|
|
224
|
+
targetTokens: tool.schema.number().optional().describe("Estimated target after compression (heuristic, not exact). Lower = more aggressive. Default uses soft config floor."),
|
|
225
|
+
},
|
|
226
|
+
async execute(args, toolCtx) {
|
|
227
|
+
const result = await applyCompress(ctx, toolCtx.sessionID, args.summary, max, min, args.targetTokens)
|
|
228
|
+
toolCtx.metadata({ title: "Compress" })
|
|
229
|
+
return `Compressed: ${result.removed} messages removed. Summary: "${truncateText(args.summary, 200)}"`
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function truncateText(s, n) {
|
|
237
|
+
if (!s || s.length <= n) return s || ""
|
|
238
|
+
return s.slice(0, n) + "..."
|
|
239
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function partTokens(part) {
|
|
2
|
+
if (part.type === "text") return Math.ceil((part.text || "").length / 4)
|
|
3
|
+
if (part.type === "tool") {
|
|
4
|
+
let t = 0
|
|
5
|
+
if (part.state?.input) t += JSON.stringify(part.state.input).length / 4
|
|
6
|
+
if (part.state?.output)
|
|
7
|
+
t += (typeof part.state.output === "string" ? part.state.output : JSON.stringify(part.state.output ?? "")).length / 4
|
|
8
|
+
return Math.ceil(t)
|
|
9
|
+
}
|
|
10
|
+
return Math.ceil(JSON.stringify(part).length / 4)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function msgTokens(msg) {
|
|
14
|
+
return (Array.isArray(msg.parts) ? msg.parts : []).reduce((s, p) => s + partTokens(p), 0)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function totalTokens(messages) {
|
|
18
|
+
return (Array.isArray(messages) ? messages : []).reduce((s, m) => s + msgTokens(m), 0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Select oldest messages to remove.
|
|
23
|
+
* Returns array of { id, msg, tokens } without mutating the input.
|
|
24
|
+
* Never removes index 0 (system prompt) or the last message (latest turn).
|
|
25
|
+
*
|
|
26
|
+
* mode "auto": remove just enough to bring total under maxLimit
|
|
27
|
+
* mode "compress": remove everything down to floor (deep prune)
|
|
28
|
+
*
|
|
29
|
+
* targetOverride: when set, replaces floor/minFloor — agent explicit request
|
|
30
|
+
* overrides the soft config defaults. Used when user asks "compress to X".
|
|
31
|
+
*/
|
|
32
|
+
export function selectMessagesToReap(messages, maxLimit, minFloor, mode = "auto", targetOverride) {
|
|
33
|
+
if (!messages?.length || messages.length < 3) return []
|
|
34
|
+
|
|
35
|
+
let total = totalTokens(messages)
|
|
36
|
+
const selected = []
|
|
37
|
+
|
|
38
|
+
if (mode === "compress") {
|
|
39
|
+
const floor = targetOverride ?? minFloor
|
|
40
|
+
let i = 1
|
|
41
|
+
while (i < messages.length - 1) {
|
|
42
|
+
const t = msgTokens(messages[i])
|
|
43
|
+
if (total - t < floor) break
|
|
44
|
+
total -= t
|
|
45
|
+
selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
|
|
46
|
+
i++
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const floor = targetOverride ?? minFloor
|
|
50
|
+
let i = 1
|
|
51
|
+
while (i < messages.length - 1 && total > maxLimit) {
|
|
52
|
+
const t = msgTokens(messages[i])
|
|
53
|
+
if (total - t < floor) break
|
|
54
|
+
total -= t
|
|
55
|
+
selected.push({ id: String(messages[i].info?.id ?? i), msg: messages[i], tokens: t })
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return selected
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const STATE_DIR = path.join(os.homedir(), ".local", "share", "opencode")
|
|
6
|
+
const STATE_FILE = path.join(STATE_DIR, "ohc-state.json")
|
|
7
|
+
|
|
8
|
+
function readAll() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
|
|
11
|
+
} catch {
|
|
12
|
+
return {}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeAll(data) {
|
|
17
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
18
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), "utf8")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadOhcState(sessionId) {
|
|
22
|
+
if (!sessionId) return null
|
|
23
|
+
const all = readAll()
|
|
24
|
+
return all[sessionId] || null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveOhcState(sessionId, data) {
|
|
28
|
+
if (!sessionId) return
|
|
29
|
+
const all = readAll()
|
|
30
|
+
all[sessionId] = { ...data, updatedAt: new Date().toISOString() }
|
|
31
|
+
writeAll(all)
|
|
32
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json")
|
|
6
|
+
const CACHE_ROOT = path.join(os.homedir(), ".cache", "opencode", "packages")
|
|
7
|
+
|
|
8
|
+
function detectInstallMethod() {
|
|
9
|
+
let raw = {}
|
|
10
|
+
try {
|
|
11
|
+
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"))
|
|
12
|
+
} catch {
|
|
13
|
+
return { method: "unknown", entry: null, reason: `cannot read ${CONFIG_PATH}` }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const plugins = Array.isArray(raw.plugin) ? raw.plugin : []
|
|
17
|
+
for (const p of plugins) {
|
|
18
|
+
const entry = typeof p === "string" ? p : (Array.isArray(p) && typeof p[0] === "string" ? p[0] : "")
|
|
19
|
+
if (!entry.startsWith("openhermes")) continue
|
|
20
|
+
|
|
21
|
+
if (entry.includes("@git+https://") || entry.includes("@git+ssh://")) {
|
|
22
|
+
const repoUrl = entry.replace(/^openhermes@/, "")
|
|
23
|
+
return { method: "git", entry, repoUrl }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (entry === "openhermes") {
|
|
27
|
+
return { method: "npm", entry }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { method: "other", entry, reason: `unrecognized format: ${entry}` }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { method: "unknown", entry: null, reason: "openhermes not found in plugin config" }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findCacheDirs() {
|
|
37
|
+
const results = []
|
|
38
|
+
if (!fs.existsSync(CACHE_ROOT)) return results
|
|
39
|
+
try {
|
|
40
|
+
for (const e of fs.readdirSync(CACHE_ROOT)) {
|
|
41
|
+
const full = path.join(CACHE_ROOT, e)
|
|
42
|
+
if (e.startsWith("openhermes") && fs.statSync(full).isDirectory()) {
|
|
43
|
+
results.push({ name: e, path: full })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
return results
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleUpdateMe(ctx, input, output) {
|
|
51
|
+
const info = detectInstallMethod()
|
|
52
|
+
|
|
53
|
+
if (info.method === "unknown") {
|
|
54
|
+
output.parts.length = 0
|
|
55
|
+
output.parts.push({
|
|
56
|
+
type: "text",
|
|
57
|
+
text: `[Update-Me] Could not detect installation method.\n${info.reason}\n\nEnsure 'openhermes' is in your opencode.json plugin list:\n https://github.com/nathwn12/openhermes#setup`,
|
|
58
|
+
})
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cacheDirs = findCacheDirs()
|
|
63
|
+
|
|
64
|
+
if (cacheDirs.length === 0) {
|
|
65
|
+
output.parts.length = 0
|
|
66
|
+
output.parts.push({
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `[Update-Me] No cached version found. Already at the latest (method: ${info.method}). Restart OpenCode if you suspect a stale install.`,
|
|
69
|
+
})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const deleted = []
|
|
74
|
+
const failed = []
|
|
75
|
+
for (const dir of cacheDirs) {
|
|
76
|
+
try {
|
|
77
|
+
fs.rmSync(dir.path, { recursive: true, force: true })
|
|
78
|
+
deleted.push(dir.name)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
failed.push({ name: dir.name, error: e.message })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
output.parts.length = 0
|
|
85
|
+
let msg = `[Update-Me] OpenHermes update (${info.method})\n`
|
|
86
|
+
|
|
87
|
+
if (deleted.length > 0) {
|
|
88
|
+
msg += `\nCleared:\n`
|
|
89
|
+
for (const d of deleted) msg += ` ✓ ${d}\n`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (failed.length > 0) {
|
|
93
|
+
msg += `\n⚠ Could not remove (file may be locked):\n`
|
|
94
|
+
for (const f of failed) msg += ` ✗ ${f.name} — ${f.error}\n`
|
|
95
|
+
msg += `Try deleting manually:\n ${CACHE_ROOT}\n`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
msg += `\nRestart OpenCode to load the latest OpenHermes from ${info.method === "git" ? "git HEAD" : "npm registry"}.`
|
|
99
|
+
|
|
100
|
+
output.parts.push({ type: "text", text: msg })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function UpdaterPlugin(ctx) {
|
|
104
|
+
return {
|
|
105
|
+
"command.execute.before": async (input, output) => {
|
|
106
|
+
if (input.command !== "update-me") return
|
|
107
|
+
await handleUpdateMe(ctx, input, output)
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openhermes",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
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",
|
package/skill-builder.mjs
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import os from "node:os"
|
|
4
|
-
import { atomicWriteJson, fingerprintEnvironment,
|
|
4
|
+
import { atomicWriteJson, fingerprintEnvironment, readJson, sanitizeRecord } from "./lib/hardening.mjs"
|
|
5
5
|
import { getConfigRoot, getDataRoot, getMemoryRoot } from "./lib/paths.mjs"
|
|
6
6
|
|
|
7
|
-
function readJson(fp, fallback) {
|
|
8
|
-
try { return JSON.parse(fs.readFileSync(fp, "utf8")) } catch { return fallback }
|
|
9
|
-
}
|
|
10
|
-
|
|
11
7
|
function buildEnvironmentFingerprint(root, directory, project) {
|
|
12
8
|
return fingerprintEnvironment({
|
|
13
9
|
cwd: directory,
|
|
@@ -94,7 +90,7 @@ export const SkillBuilderPlugin = async ({ project, directory }) => {
|
|
|
94
90
|
})
|
|
95
91
|
atomicWriteJson(path.join(dir, "index.json"), index)
|
|
96
92
|
|
|
97
|
-
} catch (err) {}
|
|
93
|
+
} catch (err) { process.stderr.write(`[skill-builder] backlog write error: ${err?.message || err}\n`) }
|
|
98
94
|
}
|
|
99
95
|
sessionStats = { toolCalls: 0, subagents: 0, startTime: Date.now() }
|
|
100
96
|
}
|