openhermes 1.12.1 → 2.5.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.
Files changed (80) hide show
  1. package/README.md +126 -207
  2. package/autorecall.mjs +79 -12
  3. package/bootstrap.mjs +123 -24
  4. package/curator.mjs +4 -40
  5. package/harness/commands/harness-audit.md +1 -1
  6. package/harness/commands/learn.md +2 -2
  7. package/harness/commands/memory-search.md +2 -2
  8. package/harness/commands/ohc.md +13 -0
  9. package/harness/constitution/soul.md +16 -4
  10. package/harness/instructions/RUNTIME.md +6 -3
  11. package/harness/prompts/architect.txt +14 -0
  12. package/harness/prompts/build-cpp.md +15 -1
  13. package/harness/prompts/build-error-resolver.md +15 -9
  14. package/harness/prompts/build-go.md +14 -0
  15. package/harness/prompts/build-java.md +15 -1
  16. package/harness/prompts/build-kotlin.md +15 -1
  17. package/harness/prompts/build-rust.md +14 -0
  18. package/harness/prompts/code-reviewer.md +15 -9
  19. package/harness/prompts/doc-updater.md +13 -0
  20. package/harness/prompts/docs-lookup.md +11 -0
  21. package/harness/prompts/e2e-runner.txt +12 -0
  22. package/harness/prompts/explore.md +16 -4
  23. package/harness/prompts/harness-optimizer.md +12 -0
  24. package/harness/prompts/loop-operator.md +11 -0
  25. package/harness/prompts/planner.md +15 -9
  26. package/harness/prompts/refactor-cleaner.md +14 -0
  27. package/harness/prompts/review-cpp.md +14 -1
  28. package/harness/prompts/review-database.md +13 -0
  29. package/harness/prompts/review-go.md +13 -0
  30. package/harness/prompts/review-java.md +14 -1
  31. package/harness/prompts/review-kotlin.md +13 -0
  32. package/harness/prompts/review-python.md +14 -1
  33. package/harness/prompts/review-rust.md +13 -0
  34. package/harness/prompts/security-reviewer.md +15 -9
  35. package/harness/prompts/tdd-guide.md +14 -0
  36. package/harness/rules/audit.md +2 -2
  37. package/harness/rules/delegation.md +0 -2
  38. package/harness/rules/handoff.md +267 -0
  39. package/harness/rules/memory-management.md +4 -4
  40. package/harness/rules/precedence.md +1 -1
  41. package/harness/rules/retrieval.md +5 -5
  42. package/harness/rules/runtime-guards.md +1 -1
  43. package/harness/rules/self-heal.md +1 -1
  44. package/harness/rules/session-start.md +5 -5
  45. package/harness/rules/skills-management.md +2 -2
  46. package/harness/rules/verification.md +4 -4
  47. package/harness/scripts/sync-commands.mjs +259 -0
  48. package/index.mjs +6 -2
  49. package/lib/ambient-memory.mjs +167 -0
  50. package/lib/handoff.mjs +176 -0
  51. package/lib/hardening.mjs +13 -8
  52. package/lib/memory-tools-plugin.mjs +107 -54
  53. package/lib/ohc/block-sync.mjs +69 -0
  54. package/lib/ohc/compress/search.mjs +152 -0
  55. package/lib/ohc/compress/state.mjs +76 -0
  56. package/lib/ohc/config.mjs +172 -16
  57. package/lib/ohc/message-ids.mjs +168 -0
  58. package/lib/ohc/notify.mjs +150 -0
  59. package/lib/ohc/protected-patterns.mjs +54 -0
  60. package/lib/ohc/prune-apply.mjs +134 -0
  61. package/lib/ohc/pruner.mjs +406 -55
  62. package/lib/ohc/reaper.mjs +12 -3
  63. package/lib/ohc/state.mjs +246 -15
  64. package/lib/ohc/strategies/deduplication.mjs +72 -0
  65. package/lib/ohc/strategies/index.mjs +2 -0
  66. package/lib/ohc/strategies/purge-errors.mjs +43 -0
  67. package/lib/ohc/token-utils.mjs +26 -0
  68. package/lib/ohc/updater.mjs +36 -13
  69. package/lib/paths.mjs +0 -3
  70. package/lib/search.mjs +48 -0
  71. package/package.json +6 -2
  72. package/schemas/audit.schema.json +22 -1
  73. package/schemas/backlog.schema.json +23 -2
  74. package/schemas/checkpoint.schema.json +23 -2
  75. package/schemas/constraint.schema.json +23 -2
  76. package/schemas/decision.schema.json +23 -2
  77. package/schemas/instinct.schema.json +23 -2
  78. package/schemas/mistake.schema.json +23 -2
  79. package/schemas/verification_receipt.schema.json +23 -2
  80. package/skill-builder.mjs +12 -23
@@ -0,0 +1,152 @@
1
+ import { parseBoundaryId, formatBlockRef } from "../message-ids.mjs"
2
+ import { totalTokens } from "../reaper.mjs"
3
+
4
+ export function buildSearchContext(state, rawMessages) {
5
+ const rawMessagesById = new Map()
6
+ const rawIndexById = new Map()
7
+
8
+ for (const msg of rawMessages) {
9
+ if (msg.info?.id) rawMessagesById.set(msg.info.id, msg)
10
+ }
11
+ for (let i = 0; i < rawMessages.length; i++) {
12
+ const msg = rawMessages[i]
13
+ if (msg?.info?.id) rawIndexById.set(msg.info.id, i)
14
+ }
15
+
16
+ const summaryByBlockId = new Map()
17
+ const ms = state.prune?.messages
18
+ if (ms?.blocksById) {
19
+ for (const [blockId, block] of ms.blocksById) {
20
+ if (block.active) summaryByBlockId.set(blockId, block)
21
+ }
22
+ }
23
+
24
+ return { rawMessages, rawMessagesById, rawIndexById, summaryByBlockId }
25
+ }
26
+
27
+ export function resolveBoundaryIds(context, state, startId, endId) {
28
+ const lookup = buildBoundaryLookup(context, state)
29
+
30
+ const parsedStart = parseBoundaryId(startId)
31
+ const parsedEnd = parseBoundaryId(endId)
32
+
33
+ if (!parsedStart) throw new Error(`startId "${startId}" is invalid. Use ohcNNNN (message) or bkNN (block).`)
34
+ if (!parsedEnd) throw new Error(`endId "${endId}" is invalid. Use ohcNNNN (message) or bkNN (block).`)
35
+
36
+ const startRef = lookup.get(parsedStart.ref)
37
+ const endRef = lookup.get(parsedEnd.ref)
38
+
39
+ if (!startRef) throw new Error(`startId ${parsedStart.ref} not found in context.`)
40
+ if (!endRef) throw new Error(`endId ${parsedEnd.ref} not found in context.`)
41
+
42
+ if (startRef.rawIndex > endRef.rawIndex) {
43
+ throw new Error(`startId ${parsedStart.ref} appears after endId ${parsedEnd.ref}. Start must come before end.`)
44
+ }
45
+
46
+ return { startReference: startRef, endReference: endRef }
47
+ }
48
+
49
+ function buildBoundaryLookup(context, state) {
50
+ const lookup = new Map()
51
+
52
+ for (const [msgRef, rawId] of state.messageIds.byRef) {
53
+ const rawMsg = context.rawMessagesById.get(rawId)
54
+ if (!rawMsg) continue
55
+
56
+ const rawIndex = context.rawIndexById.get(rawId)
57
+ if (rawIndex === undefined) continue
58
+
59
+ lookup.set(msgRef, { kind: "message", rawIndex, messageId: rawId })
60
+ }
61
+
62
+ const summaries = Array.from(context.summaryByBlockId.values()).sort((a, b) => a.blockId - b.blockId)
63
+ for (const summary of summaries) {
64
+ const anchorMsg = context.rawMessagesById.get(summary.anchorMessageId)
65
+ if (!anchorMsg) continue
66
+
67
+ const rawIndex = context.rawIndexById.get(summary.anchorMessageId)
68
+ if (rawIndex === undefined) continue
69
+
70
+ const bkRef = formatBlockRef(summary.blockId)
71
+ if (!lookup.has(bkRef)) {
72
+ lookup.set(bkRef, {
73
+ kind: "compressed-block",
74
+ rawIndex,
75
+ blockId: summary.blockId,
76
+ anchorMessageId: summary.anchorMessageId,
77
+ })
78
+ }
79
+ }
80
+
81
+ return lookup
82
+ }
83
+
84
+ export function resolveSelection(context, startReference, endReference) {
85
+ const messageIds = []
86
+ const messageSeen = new Set()
87
+ const toolIds = []
88
+ const toolSeen = new Set()
89
+ const requiredBlockIds = []
90
+ const requiredBlockSeen = new Set()
91
+
92
+ for (let i = startReference.rawIndex; i <= endReference.rawIndex; i++) {
93
+ const msg = context.rawMessages[i]
94
+ if (!msg) continue
95
+
96
+ const mid = msg.info?.id
97
+ if (mid && !messageSeen.has(mid)) {
98
+ messageSeen.add(mid)
99
+ messageIds.push(mid)
100
+ }
101
+
102
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
103
+ for (const part of parts) {
104
+ if (part.type !== "tool" || !part.callID || toolSeen.has(part.callID)) continue
105
+ toolSeen.add(part.callID)
106
+ toolIds.push(part.callID)
107
+ }
108
+ }
109
+
110
+ const selectedIds = new Set(messageIds)
111
+ const summariesInRange = []
112
+ for (const block of context.summaryByBlockId.values()) {
113
+ if (!selectedIds.has(block.anchorMessageId)) continue
114
+ const idx = context.rawIndexById.get(block.anchorMessageId)
115
+ if (idx === undefined) continue
116
+ summariesInRange.push({ blockId: block.blockId, rawIndex: idx })
117
+ }
118
+
119
+ summariesInRange.sort((a, b) => a.rawIndex - b.rawIndex || a.blockId - b.blockId)
120
+ for (const s of summariesInRange) {
121
+ if (!requiredBlockSeen.has(s.blockId)) {
122
+ requiredBlockSeen.add(s.blockId)
123
+ requiredBlockIds.push(s.blockId)
124
+ }
125
+ }
126
+
127
+ if (messageIds.length === 0) {
128
+ throw new Error("No messages found in the specified range.")
129
+ }
130
+
131
+ return { startReference, endReference, messageIds, toolIds, requiredBlockIds }
132
+ }
133
+
134
+ export function resolveAnchorMessageId(startReference) {
135
+ if (startReference.kind === "compressed-block") {
136
+ if (!startReference.anchorMessageId) throw new Error("Compressed block has no anchor message ID")
137
+ return startReference.anchorMessageId
138
+ }
139
+ if (!startReference.messageId) throw new Error("No message ID in start reference")
140
+ return startReference.messageId
141
+ }
142
+
143
+ export function validateNonOverlapping(ranges) {
144
+ const sorted = [...ranges].sort((a, b) => a.selection.startReference.rawIndex - b.selection.startReference.rawIndex)
145
+ for (let i = 1; i < sorted.length; i++) {
146
+ const prev = sorted[i - 1]
147
+ const curr = sorted[i]
148
+ if (curr.selection.startReference.rawIndex <= prev.selection.endReference.rawIndex) {
149
+ throw new Error(`content[${prev.index}] (${prev.entry.startId}..${prev.entry.endId}) overlaps content[${curr.index}] (${curr.entry.startId}..${curr.entry.endId}).`)
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,76 @@
1
+ export function allocateBlockId(state) {
2
+ const ms = state.prune.messages
3
+ const id = ms.nextBlockId
4
+ ms.nextBlockId++
5
+ return id
6
+ }
7
+
8
+ export function allocateRunId(state) {
9
+ const ms = state.prune.messages
10
+ const id = ms.nextRunId
11
+ ms.nextRunId++
12
+ return id
13
+ }
14
+
15
+ export function wrapBlockSummary(blockId, summary) {
16
+ return `[OHC: Compressed bk${blockId}]\n\n${summary}\n<ohc-ref>bk${blockId}</ohc-ref>`
17
+ }
18
+
19
+ export function applyCompressionState(state, input, selection, anchorMessageId, blockId, storedSummary, consumedBlockIds) {
20
+ const ms = state.prune.messages
21
+
22
+ const block = {
23
+ blockId,
24
+ active: true,
25
+ topic: input.topic,
26
+ batchTopic: input.batchTopic || input.topic,
27
+ mode: input.mode || "range",
28
+ runId: input.runId,
29
+ compressMessageId: input.compressMessageId,
30
+ compressCallId: input.compressCallId || null,
31
+ anchorMessageId,
32
+ startId: input.startId,
33
+ endId: input.endId,
34
+ summary: storedSummary,
35
+ summaryTokens: input.summaryTokens || 0,
36
+ compressedTokens: 0,
37
+ consumedBlockIds: Array.isArray(consumedBlockIds) ? consumedBlockIds : [],
38
+ deactivatedByBlockId: undefined,
39
+ deactivatedByUser: false,
40
+ deactivatedAt: undefined,
41
+ createdAt: Date.now(),
42
+ }
43
+
44
+ ms.blocksById.set(blockId, block)
45
+ ms.activeBlockIds.add(blockId)
46
+
47
+ if (anchorMessageId) {
48
+ ms.activeByAnchorMessageId.set(anchorMessageId, blockId)
49
+ }
50
+
51
+ for (const rawMessageId of selection.messageIds) {
52
+ let entry = ms.byMessageId.get(rawMessageId)
53
+ if (!entry) {
54
+ entry = { tokenCount: 0, allBlockIds: [], activeBlockIds: [] }
55
+ ms.byMessageId.set(rawMessageId, entry)
56
+ }
57
+ if (!entry.allBlockIds.includes(blockId)) {
58
+ entry.allBlockIds.push(blockId)
59
+ }
60
+ if (!entry.activeBlockIds.includes(blockId)) {
61
+ entry.activeBlockIds.push(blockId)
62
+ }
63
+ }
64
+
65
+ for (const cid of consumedBlockIds) {
66
+ const cb = ms.blocksById.get(cid)
67
+ if (cb) {
68
+ cb.active = false
69
+ cb.deactivatedAt = Date.now()
70
+ cb.deactivatedByBlockId = blockId
71
+ ms.activeBlockIds.delete(cid)
72
+ }
73
+ }
74
+
75
+ return block
76
+ }
@@ -2,29 +2,185 @@ import fs from "node:fs"
2
2
  import path from "node:path"
3
3
  import os from "node:os"
4
4
 
5
- const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "ohc.json")
5
+ const GLOBAL_DIR = path.join(os.homedir(), ".config", "opencode")
6
+ const GLOBAL_PATH = path.join(GLOBAL_DIR, "ohc.json")
7
+ const GLOBAL_PATH_JSONC = path.join(GLOBAL_DIR, "ohc.jsonc")
6
8
 
7
- const DEFAULTS = { enabled: true, max: 200000, min: 50000 }
9
+ const DEFAULTS = {
10
+ enabled: true,
11
+ notification: "chat",
12
+ notificationMode: "detailed",
13
+ max: 150000,
14
+ min: 50000,
15
+ manualMode: { enabled: false, automaticStrategies: true },
16
+ turnProtection: { enabled: false, turns: 4 },
17
+ protectedFilePatterns: [],
18
+ compress: {
19
+ maxContextLimit: 150000,
20
+ minContextLimit: 50000,
21
+ nudgeFrequency: 5,
22
+ iterationNudgeThreshold: 15,
23
+ nudgeForce: "soft",
24
+ protectedTools: ["task", "skill", "todowrite", "todoread"],
25
+ protectUserMessages: false,
26
+ summaryBuffer: true,
27
+ },
28
+ strategies: {
29
+ deduplication: { enabled: true, protectedTools: [] },
30
+ purgeErrors: { enabled: true, turns: 4, protectedTools: [] },
31
+ },
32
+ }
8
33
 
9
- function writeDefaults() {
10
- try {
11
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true })
12
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf8")
13
- } catch {}
34
+ function findOpencodeDir(startDir) {
35
+ let current = startDir
36
+ while (current && current.length > 3) {
37
+ const candidate = path.join(current, ".opencode")
38
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
39
+ return candidate
40
+ }
41
+ const parent = path.dirname(current)
42
+ if (parent === current) break
43
+ current = parent
44
+ }
45
+ return null
14
46
  }
15
47
 
16
- export function loadConfig() {
17
- let raw = {}
48
+ function loadFile(filePath) {
18
49
  try {
19
- raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"))
50
+ const content = fs.readFileSync(filePath, "utf8").trim()
51
+ if (!content) return null
52
+ if (filePath.endsWith(".jsonc")) {
53
+ const stripped = content
54
+ .replace(/"(?:[^"\\]|\\.)*"|\/\/.*/gm, m => m.startsWith('"') ? m : "")
55
+ .replace(/\/\*[\s\S]*?\*\//g, "")
56
+ return JSON.parse(stripped)
57
+ }
58
+ return JSON.parse(content)
20
59
  } catch {
21
- writeDefaults()
22
- return { ...DEFAULTS }
60
+ return null
61
+ }
62
+ }
63
+
64
+ function mergeDeep(base, override) {
65
+ if (!override || typeof override !== "object") return base
66
+ const result = { ...base }
67
+ for (const key of Object.keys(override)) {
68
+ if (override[key] === undefined) continue
69
+ if (typeof override[key] === "object" && !Array.isArray(override[key]) && override[key] !== null) {
70
+ result[key] = mergeDeep(base[key] || {}, override[key])
71
+ } else {
72
+ result[key] = override[key]
73
+ }
74
+ }
75
+ return result
76
+ }
77
+
78
+ function mergeArrays(base, override) {
79
+ if (!override || !Array.isArray(override)) return base
80
+ return [...new Set([...base, ...override])]
81
+ }
82
+
83
+ function mergeManualMode(base, override) {
84
+ if (!override || typeof override !== "object") return base
85
+ return {
86
+ enabled: override.enabled ?? base.enabled,
87
+ automaticStrategies: override.automaticStrategies ?? base.automaticStrategies,
88
+ }
89
+ }
90
+
91
+ function mergeTurnProtection(base, override) {
92
+ if (!override || typeof override !== "object") return base
93
+ return {
94
+ enabled: override.enabled ?? base.enabled,
95
+ turns: override.turns ?? base.turns,
96
+ }
97
+ }
98
+
99
+ function mergeCompress(base, override) {
100
+ if (!override || typeof override !== "object") return base
101
+ return {
102
+ maxContextLimit: override.maxContextLimit ?? base.maxContextLimit,
103
+ minContextLimit: override.minContextLimit ?? base.minContextLimit,
104
+ nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
105
+ iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
106
+ nudgeForce: override.nudgeForce ?? base.nudgeForce,
107
+ protectedTools: mergeArrays(base.protectedTools, override.protectedTools),
108
+ protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
109
+ summaryBuffer: override.summaryBuffer ?? base.summaryBuffer,
23
110
  }
111
+ }
112
+
113
+ function mergeStrategies(base, override) {
114
+ if (!override || typeof override !== "object") return base
115
+ return {
116
+ deduplication: {
117
+ enabled: override.deduplication?.enabled ?? base.deduplication.enabled,
118
+ protectedTools: mergeArrays(base.deduplication.protectedTools, override.deduplication?.protectedTools),
119
+ },
120
+ purgeErrors: {
121
+ enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled,
122
+ turns: override.purgeErrors?.turns ?? base.purgeErrors.turns,
123
+ protectedTools: mergeArrays(base.purgeErrors.protectedTools, override.purgeErrors?.protectedTools),
124
+ },
125
+ }
126
+ }
24
127
 
128
+ function mergeLayer(base, data) {
129
+ if (!data) return base
25
130
  return {
26
- enabled: raw.enabled !== false,
27
- max: typeof raw.max === "number" && raw.max > 0 ? raw.max : DEFAULTS.max,
28
- min: typeof raw.min === "number" ? Math.max(10000, raw.min) : DEFAULTS.min,
131
+ enabled: data.enabled ?? base.enabled,
132
+ notification: data.notification ?? base.notification,
133
+ notificationMode: data.notificationMode ?? base.notificationMode,
134
+ max: data.max ?? base.max,
135
+ min: data.min ?? base.min,
136
+ manualMode: mergeManualMode(base.manualMode, data.manualMode),
137
+ turnProtection: mergeTurnProtection(base.turnProtection, data.turnProtection),
138
+ protectedFilePatterns: mergeArrays(base.protectedFilePatterns, data.protectedFilePatterns),
139
+ compress: mergeCompress(base.compress, data.compress),
140
+ strategies: mergeStrategies(base.strategies, data.strategies),
29
141
  }
30
- }
142
+ }
143
+
144
+ export function loadConfig(ctx) {
145
+ let config = { ...DEFAULTS }
146
+
147
+ const layers = [
148
+ { path: fs.existsSync(GLOBAL_PATH_JSONC) ? GLOBAL_PATH_JSONC : (fs.existsSync(GLOBAL_PATH) ? GLOBAL_PATH : null), name: "global" },
149
+ ]
150
+
151
+ const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
152
+ if (opencodeConfigDir) {
153
+ const cdJsonc = path.join(opencodeConfigDir, "ohc.jsonc")
154
+ const cdJson = path.join(opencodeConfigDir, "ohc.json")
155
+ const cdPath = fs.existsSync(cdJsonc) ? cdJsonc : (fs.existsSync(cdJson) ? cdJson : null)
156
+ if (cdPath) layers.push({ path: cdPath, name: "configDir" })
157
+ }
158
+
159
+ if (ctx?.directory) {
160
+ const opencodeDir = findOpencodeDir(ctx.directory)
161
+ if (opencodeDir) {
162
+ const pjJsonc = path.join(opencodeDir, "ohc.jsonc")
163
+ const pjJson = path.join(opencodeDir, "ohc.json")
164
+ const pjPath = fs.existsSync(pjJsonc) ? pjJsonc : (fs.existsSync(pjJson) ? pjJson : null)
165
+ if (pjPath) layers.push({ path: pjPath, name: "project" })
166
+ }
167
+ }
168
+
169
+ for (const layer of layers) {
170
+ if (!layer.path) continue
171
+ const data = loadFile(layer.path)
172
+ if (data) config = mergeLayer(config, data)
173
+ }
174
+
175
+ config.max = typeof config.max === "number" && config.max > 0 ? config.max : DEFAULTS.max
176
+ config.min = typeof config.min === "number" ? Math.max(10000, Math.min(config.min, config.max - 10000)) : DEFAULTS.min
177
+
178
+ return config
179
+ }
180
+
181
+ function writeDefaults() {
182
+ fs.mkdirSync(GLOBAL_DIR, { recursive: true })
183
+ if (!fs.existsSync(GLOBAL_PATH) && !fs.existsSync(GLOBAL_PATH_JSONC)) {
184
+ fs.writeFileSync(GLOBAL_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf8")
185
+ }
186
+ }
@@ -0,0 +1,168 @@
1
+ const MESSAGE_REF_REGEX = /^ohc(\d{4})$/
2
+ const BLOCK_REF_REGEX = /^bk([1-9]\d*)$/
3
+ const OHCTAG = "ohc-ref"
4
+
5
+ export function formatMessageRef(index) {
6
+ if (!Number.isInteger(index) || index < 1 || index > 9999) {
7
+ throw new Error(`OHC ref index out of bounds: ${index}`)
8
+ }
9
+ return `ohc${index.toString().padStart(4, "0")}`
10
+ }
11
+
12
+ export function formatBlockRef(blockId) {
13
+ if (!Number.isInteger(blockId) || blockId < 1) {
14
+ throw new Error(`Invalid block ID: ${blockId}`)
15
+ }
16
+ return `bk${blockId}`
17
+ }
18
+
19
+ export function parseMessageRef(ref) {
20
+ const m = (ref || "").trim().toLowerCase().match(MESSAGE_REF_REGEX)
21
+ if (!m) return null
22
+ const idx = parseInt(m[1], 10)
23
+ return Number.isInteger(idx) && idx >= 1 && idx <= 9999 ? idx : null
24
+ }
25
+
26
+ export function parseBlockRef(ref) {
27
+ const m = (ref || "").trim().toLowerCase().match(BLOCK_REF_REGEX)
28
+ if (!m) return null
29
+ const id = parseInt(m[1], 10)
30
+ return Number.isInteger(id) ? id : null
31
+ }
32
+
33
+ export function parseBoundaryId(id) {
34
+ const norm = (id || "").trim().toLowerCase()
35
+ const msgIdx = parseMessageRef(norm)
36
+ if (msgIdx !== null) {
37
+ return { kind: "message", ref: formatMessageRef(msgIdx), index: msgIdx }
38
+ }
39
+ const bkId = parseBlockRef(norm)
40
+ if (bkId !== null) {
41
+ return { kind: "compressed-block", ref: formatBlockRef(bkId), blockId: bkId }
42
+ }
43
+ return null
44
+ }
45
+
46
+ function escapeXml(s) {
47
+ return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
48
+ }
49
+
50
+ export function formatOhcTag(ref, attrs) {
51
+ const serialized = Object.entries(attrs || {})
52
+ .filter(([, v]) => typeof v === "string" && v.length > 0)
53
+ .sort(([a], [b]) => a.localeCompare(b))
54
+ .map(([k, v]) => ` ${k}="${escapeXml(v)}"`)
55
+ .join("")
56
+ return `\n<${OHCTAG}${serialized}>${ref}</${OHCTAG}>`
57
+ }
58
+
59
+ export function isIgnoredUserMessage(message) {
60
+ if (!message?.info || message.info.role !== "user") return false
61
+ const parts = Array.isArray(message.parts) ? message.parts : []
62
+ if (parts.length === 0) return true
63
+ return parts.every(p => p.ignored)
64
+ }
65
+
66
+ export function assignMessageRefs(state, messages) {
67
+ let assigned = 0
68
+ let skippedFirstUser = false
69
+
70
+ for (const msg of messages) {
71
+ if (isIgnoredUserMessage(msg)) continue
72
+
73
+ if (state.isSubAgent && !skippedFirstUser && msg.info.role === "user") {
74
+ skippedFirstUser = true
75
+ continue
76
+ }
77
+
78
+ const rawId = msg.info?.id
79
+ if (typeof rawId !== "string" || !rawId) continue
80
+
81
+ const existing = state.messageIds.byRawId.get(rawId)
82
+ if (existing) {
83
+ if (state.messageIds.byRef.get(existing) !== rawId) {
84
+ state.messageIds.byRef.set(existing, rawId)
85
+ }
86
+ continue
87
+ }
88
+
89
+ const ref = allocateNextRef(state)
90
+ state.messageIds.byRawId.set(rawId, ref)
91
+ state.messageIds.byRef.set(ref, rawId)
92
+ assigned++
93
+ }
94
+
95
+ return assigned
96
+ }
97
+
98
+ function allocateNextRef(state) {
99
+ let candidate = Number.isInteger(state.messageIds.nextRef)
100
+ ? Math.max(1, state.messageIds.nextRef)
101
+ : 1
102
+
103
+ for (let attempt = 0; attempt < 9999; attempt++) {
104
+ if (candidate > 9999) candidate = 1
105
+ const ref = formatMessageRef(candidate)
106
+ if (!state.messageIds.byRef.has(ref)) {
107
+ state.messageIds.nextRef = candidate + 1
108
+ return ref
109
+ }
110
+ candidate++
111
+ }
112
+
113
+ state.messageIds.nextRef = 1
114
+ return formatMessageRef(1)
115
+ }
116
+
117
+ export function injectMessageIds(state, messages) {
118
+ for (const msg of messages) {
119
+ if (isIgnoredUserMessage(msg)) continue
120
+ const msgRef = state.messageIds.byRawId.get(msg.info?.id)
121
+ if (!msgRef) continue
122
+ const tag = formatOhcTag(msgRef)
123
+
124
+ if (msg.info.role === "user") {
125
+ let injected = false
126
+ for (const part of msg.parts) {
127
+ if (part.type === "text") {
128
+ part.text += tag
129
+ injected = true
130
+ break
131
+ }
132
+ }
133
+ if (!injected) {
134
+ msg.parts.push({ type: "text", text: tag })
135
+ }
136
+ continue
137
+ }
138
+
139
+ if (msg.info.role !== "assistant") continue
140
+
141
+ let hasContent = Array.isArray(msg.parts) && msg.parts.some(p => p.type === "text" || p.type === "tool")
142
+ if (!hasContent) continue
143
+
144
+ let injected = false
145
+ for (const part of msg.parts) {
146
+ if (part.type !== "tool" || typeof part.state?.output !== "string") continue
147
+ part.state.output = (part.state.output || "") + tag
148
+ injected = true
149
+ break
150
+ }
151
+
152
+ if (injected) continue
153
+
154
+ const lastText = [...msg.parts].reverse().find(p => p.type === "text")
155
+ if (lastText) {
156
+ lastText.text += tag
157
+ continue
158
+ }
159
+
160
+ const firstToolIdx = msg.parts.findIndex(p => p.type === "tool")
161
+ const synth = { type: "text", text: tag }
162
+ if (firstToolIdx === -1) {
163
+ msg.parts.push(synth)
164
+ } else {
165
+ msg.parts.splice(firstToolIdx, 0, synth)
166
+ }
167
+ }
168
+ }