openhermes 1.5.6 → 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -111
  3. package/autorecall.mjs +2 -12
  4. package/bootstrap.mjs +158 -8
  5. package/curator.mjs +1 -5
  6. package/harness/commands/checkpoint.md +68 -0
  7. package/harness/commands/eval.md +89 -0
  8. package/harness/commands/go-build.md +87 -0
  9. package/harness/commands/go-review.md +71 -0
  10. package/harness/commands/harness-audit.md +90 -0
  11. package/harness/commands/learn.md +2 -2
  12. package/harness/commands/loop-start.md +38 -0
  13. package/harness/commands/loop-status.md +30 -0
  14. package/harness/commands/memory-search.md +2 -2
  15. package/harness/commands/model-route.md +32 -0
  16. package/harness/commands/orchestrate.md +88 -0
  17. package/harness/commands/quality-gate.md +35 -0
  18. package/harness/commands/refactor-clean.md +102 -0
  19. package/harness/commands/rust-build.md +78 -0
  20. package/harness/commands/rust-review.md +65 -0
  21. package/harness/commands/setup-pm.md +65 -0
  22. package/harness/commands/skill-create.md +99 -0
  23. package/harness/commands/test-coverage.md +80 -0
  24. package/harness/commands/update-codemaps.md +81 -0
  25. package/harness/commands/update-docs.md +67 -0
  26. package/harness/commands/verify.md +68 -0
  27. package/harness/instructions/CONVENTIONS.md +206 -0
  28. package/harness/instructions/RUNTIME.md +8 -1
  29. package/harness/prompts/build-cpp.md +84 -0
  30. package/harness/prompts/build-error-resolver.md +2 -1
  31. package/harness/prompts/build-go.md +326 -0
  32. package/harness/prompts/build-java.md +126 -0
  33. package/harness/prompts/build-kotlin.md +123 -0
  34. package/harness/prompts/build-rust.md +94 -0
  35. package/harness/prompts/code-reviewer.md +2 -1
  36. package/harness/prompts/doc-updater.md +193 -0
  37. package/harness/prompts/docs-lookup.md +60 -0
  38. package/harness/prompts/explore.md +1 -0
  39. package/harness/prompts/harness-optimizer.md +30 -0
  40. package/harness/prompts/loop-operator.md +42 -0
  41. package/harness/prompts/planner.md +3 -2
  42. package/harness/prompts/refactor-cleaner.md +242 -0
  43. package/harness/prompts/review-cpp.md +68 -0
  44. package/harness/prompts/review-database.md +248 -0
  45. package/harness/prompts/review-go.md +244 -0
  46. package/harness/prompts/review-java.md +100 -0
  47. package/harness/prompts/review-kotlin.md +130 -0
  48. package/harness/prompts/review-python.md +88 -0
  49. package/harness/prompts/review-rust.md +64 -0
  50. package/harness/prompts/security-reviewer.md +3 -2
  51. package/harness/prompts/tdd-guide.md +214 -0
  52. package/harness/rules/delegation.md +28 -22
  53. package/harness/rules/memory-management.md +4 -4
  54. package/harness/rules/retrieval.md +5 -5
  55. package/harness/rules/runtime-guards.md +1 -1
  56. package/harness/rules/session-start.md +4 -4
  57. package/harness/rules/skills-management.md +2 -2
  58. package/harness/rules/state-drift.md +1 -1
  59. package/harness/rules/verification.md +4 -4
  60. package/harness/skills/coding-standards/SKILL.md +1 -1
  61. package/index.mjs +25 -4
  62. package/lib/hardening.mjs +11 -1
  63. package/lib/memory-tools-plugin.mjs +84 -71
  64. package/lib/ohc/config.mjs +30 -0
  65. package/lib/ohc/pruner.mjs +239 -0
  66. package/lib/ohc/reaper.mjs +61 -0
  67. package/lib/ohc/state.mjs +32 -0
  68. package/lib/ohc/updater.mjs +110 -0
  69. package/package.json +1 -1
  70. package/skill-builder.mjs +2 -6
@@ -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.5.6",
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, isTruthy, sanitizeRecord } from "./lib/hardening.mjs"
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
  }