openhermes 1.13.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 (78) hide show
  1. package/README.md +125 -206
  2. package/autorecall.mjs +79 -12
  3. package/bootstrap.mjs +122 -25
  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/constitution/soul.md +16 -4
  9. package/harness/instructions/RUNTIME.md +6 -3
  10. package/harness/prompts/architect.txt +14 -0
  11. package/harness/prompts/build-cpp.md +15 -1
  12. package/harness/prompts/build-error-resolver.md +15 -9
  13. package/harness/prompts/build-go.md +14 -0
  14. package/harness/prompts/build-java.md +15 -1
  15. package/harness/prompts/build-kotlin.md +15 -1
  16. package/harness/prompts/build-rust.md +14 -0
  17. package/harness/prompts/code-reviewer.md +15 -9
  18. package/harness/prompts/doc-updater.md +13 -0
  19. package/harness/prompts/docs-lookup.md +11 -0
  20. package/harness/prompts/e2e-runner.txt +12 -0
  21. package/harness/prompts/explore.md +16 -4
  22. package/harness/prompts/harness-optimizer.md +12 -0
  23. package/harness/prompts/loop-operator.md +11 -0
  24. package/harness/prompts/planner.md +15 -9
  25. package/harness/prompts/refactor-cleaner.md +14 -0
  26. package/harness/prompts/review-cpp.md +14 -1
  27. package/harness/prompts/review-database.md +13 -0
  28. package/harness/prompts/review-go.md +13 -0
  29. package/harness/prompts/review-java.md +14 -1
  30. package/harness/prompts/review-kotlin.md +13 -0
  31. package/harness/prompts/review-python.md +14 -1
  32. package/harness/prompts/review-rust.md +13 -0
  33. package/harness/prompts/security-reviewer.md +15 -9
  34. package/harness/prompts/tdd-guide.md +14 -0
  35. package/harness/rules/audit.md +2 -2
  36. package/harness/rules/delegation.md +0 -2
  37. package/harness/rules/handoff.md +267 -0
  38. package/harness/rules/memory-management.md +4 -4
  39. package/harness/rules/precedence.md +1 -1
  40. package/harness/rules/retrieval.md +5 -5
  41. package/harness/rules/runtime-guards.md +1 -1
  42. package/harness/rules/self-heal.md +1 -1
  43. package/harness/rules/session-start.md +5 -5
  44. package/harness/rules/skills-management.md +2 -2
  45. package/harness/rules/verification.md +4 -4
  46. package/index.mjs +6 -2
  47. package/lib/ambient-memory.mjs +167 -0
  48. package/lib/handoff.mjs +176 -0
  49. package/lib/hardening.mjs +13 -8
  50. package/lib/memory-tools-plugin.mjs +107 -54
  51. package/lib/ohc/block-sync.mjs +69 -0
  52. package/lib/ohc/compress/search.mjs +152 -0
  53. package/lib/ohc/compress/state.mjs +76 -0
  54. package/lib/ohc/config.mjs +172 -16
  55. package/lib/ohc/message-ids.mjs +168 -0
  56. package/lib/ohc/notify.mjs +150 -0
  57. package/lib/ohc/protected-patterns.mjs +54 -0
  58. package/lib/ohc/prune-apply.mjs +134 -0
  59. package/lib/ohc/pruner.mjs +406 -55
  60. package/lib/ohc/reaper.mjs +12 -3
  61. package/lib/ohc/state.mjs +246 -15
  62. package/lib/ohc/strategies/deduplication.mjs +72 -0
  63. package/lib/ohc/strategies/index.mjs +2 -0
  64. package/lib/ohc/strategies/purge-errors.mjs +43 -0
  65. package/lib/ohc/token-utils.mjs +26 -0
  66. package/lib/ohc/updater.mjs +36 -13
  67. package/lib/paths.mjs +0 -3
  68. package/lib/search.mjs +48 -0
  69. package/package.json +1 -1
  70. package/schemas/audit.schema.json +22 -1
  71. package/schemas/backlog.schema.json +23 -2
  72. package/schemas/checkpoint.schema.json +23 -2
  73. package/schemas/constraint.schema.json +23 -2
  74. package/schemas/decision.schema.json +23 -2
  75. package/schemas/instinct.schema.json +23 -2
  76. package/schemas/mistake.schema.json +23 -2
  77. package/schemas/verification_receipt.schema.json +23 -2
  78. package/skill-builder.mjs +12 -23
@@ -7,6 +7,9 @@ import os from "node:os"
7
7
  import { atomicWriteJson, fingerprintEnvironment, readJson, readJsonl, sanitizeRecord, truncateText } from "./hardening.mjs"
8
8
  import { findUnsupportedSchemaKeywords, validateSchema } from "./schema-validator.mjs"
9
9
  import { getMemoryRoot, getRuntimeRoot } from "./paths.mjs"
10
+ import { loadConfig } from "./ohc/config.mjs"
11
+ import { sendMemoryNotification } from "./ohc/notify.mjs"
12
+ import { scoreRelevance } from "./search.mjs"
10
13
 
11
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
15
  const SCHEMAS_DIR = path.resolve(__dirname, "..", "schemas")
@@ -85,10 +88,9 @@ function queryList(cls, limit = 10) {
85
88
  return sortRecent(filterActive(readJsonl(path.join(classDir(cls), "mistakes.jsonl")))).slice(0, limit)
86
89
  }
87
90
  const dir = classDir(cls)
88
- let files = []
89
- try { files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && f !== "index.json").map(f => path.join(dir, f)) } catch { return [] }
90
- const entries = files.map(f => readJson(f, null)).filter(Boolean).map(r => buildEntry(cls, r))
91
- return sortRecent(filterActive(entries)).slice(0, limit)
91
+ const index = readJson(path.join(dir, "index.json"), [])
92
+ if (!Array.isArray(index)) return []
93
+ return sortRecent(filterActive(index)).slice(0, limit)
92
94
  }
93
95
 
94
96
  function queryGet(cls, id) {
@@ -96,27 +98,6 @@ function queryGet(cls, id) {
96
98
  return readJson(path.join(classDir(cls), `${id}.json`), null)
97
99
  }
98
100
 
99
- function scoreRelevance(r, query, project) {
100
- const q = query.toLowerCase()
101
- let score = 0
102
- const fields = [r.summary, r.id, r.description, r.mission, r.current_state, r.failure, r.root_cause, r.fix, r.prevention, r.command, r.project, r.scope, ...(Array.isArray(r.tags) ? r.tags : []), ...(Array.isArray(r.next_actions) ? r.next_actions : []), ...(Array.isArray(r.refs) ? r.refs : [])].filter(Boolean)
103
- for (const f of fields) {
104
- const str = String(f).toLowerCase()
105
- let idx = 0; let count = 0
106
- while ((idx = str.indexOf(q, idx)) !== -1) { count++; idx += q.length }
107
- score += count * 10
108
- if (str.startsWith(q)) score += 5
109
- if (str.includes(q)) score += 2
110
- }
111
- if (r.project && r.project.toLowerCase() === (project || "").toLowerCase()) score += 20
112
- if (r.project && project && r.project.toLowerCase().includes(project.toLowerCase())) score += 10
113
- const age = Date.now() - Date.parse(r.updated_at || r.created_at || 0)
114
- if (!Number.isNaN(age)) score += Math.max(0, 10 - age / 604800000)
115
- if (r.status === "active") score += 3
116
- if (r.status === "closed") score -= 2
117
- return score
118
- }
119
-
120
101
  function enforceAuditEvidence(record) {
121
102
  const prov = isPlainObject(record.provenance) ? record.provenance : {}
122
103
  return ["db_refs", "file_refs", "log_refs"].some(k => Array.isArray(prov[k]) && prov[k].some(i => typeof i === "string" && i.trim()))
@@ -127,22 +108,71 @@ function setToolTitle(context, title) {
127
108
  context.metadata({ title })
128
109
  }
129
110
 
111
+ function fillSchemaDefaults(record, schema) {
112
+ const required = Array.isArray(schema.required) ? schema.required : []
113
+ for (const field of required) {
114
+ const prop = schema.properties?.[field]
115
+ if (!prop) { if (!(field in record)) record[field] = null; continue }
116
+
117
+ const types = Array.isArray(prop.type) ? prop.type : [prop.type]
118
+ const t = types.find(t => t !== "null") || types[0]
119
+
120
+ if (t === "object" && prop.properties) {
121
+ if (record[field] == null) record[field] = {}
122
+ if (isPlainObject(record[field])) fillSchemaDefaults(record[field], prop)
123
+ continue
124
+ }
125
+
126
+ if (field in record) continue
127
+ if (prop.default !== undefined) { record[field] = structuredClone(prop.default); continue }
128
+ if (prop.const !== undefined) { record[field] = prop.const; continue }
129
+ if (prop.enum) { record[field] = prop.enum[0]; continue }
130
+ if (t === "integer" || t === "number") {
131
+ record[field] = prop.minimum !== undefined ? prop.minimum : 0
132
+ continue
133
+ }
134
+ switch (t) {
135
+ case "string":
136
+ record[field] = prop.minLength > 1 ? "x".repeat(prop.minLength) : field
137
+ break
138
+ case "boolean": record[field] = false; break
139
+ case "array": record[field] = []; break
140
+ default: record[field] = field; break
141
+ }
142
+ }
143
+ }
144
+
145
+ function sanitizeId(raw) {
146
+ return raw.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_").trim()
147
+ }
148
+
130
149
  function handleAdd(cls, id, dataStr) {
131
150
  let parsed
132
- try { parsed = JSON.parse(dataStr) } catch (e) { return `invalid JSON: ${e.message}` }
133
- if (!isPlainObject(parsed)) return "data must be a JSON object"
151
+ try { parsed = JSON.parse(dataStr) } catch (e) { parsed = null }
152
+ if (!isPlainObject(parsed)) {
153
+ const txt = String(dataStr ?? parsed ?? "").slice(0, 200)
154
+ parsed = { summary: txt }
155
+ }
134
156
  if (!id?.trim()) return "non-blank id is required"
157
+ id = sanitizeId(id.trim())
135
158
 
136
159
  const now = new Date().toISOString()
137
- const existing = queryGet(cls, id.trim())
160
+ const existing = queryGet(cls, id)
161
+ const existingIsActive = existing && !hasExpired(existing)
138
162
  const record = { ...parsed, id, class: cls, source: parsed.source ?? "agent", status: parsed.status ?? "active", created_at: parsed.created_at ?? (existing?.created_at ?? now), updated_at: now }
139
163
 
140
164
  const schema = readJson(path.join(SCHEMAS_DIR, `${cls}.schema.json`), null)
141
165
  if (schema) {
166
+ fillSchemaDefaults(record, schema)
167
+
142
168
  const required = Array.isArray(schema.required) ? schema.required : []
143
169
  if (required.includes("provenance")) {
144
- if (!record.provenance) record.provenance = { session_id: `auto-${now}` }
145
- else if (!record.provenance.session_id) record.provenance.session_id = `auto-${now}`
170
+ if (!record.provenance?.session_id) record.provenance = { ...record.provenance, session_id: `auto-${now}` }
171
+ if (cls === "audit" && !enforceAuditEvidence(record)) {
172
+ const prov = record.provenance
173
+ if (!Array.isArray(prov.file_refs)) prov.file_refs = []
174
+ if (!prov.file_refs.some(r => typeof r === "string" && r.trim())) prov.file_refs.push("auto-filled")
175
+ }
146
176
  }
147
177
  if (required.includes("trigger") && !record.trigger) record.trigger = "manual"
148
178
  if (required.includes("success_count") && record.success_count == null) record.success_count = 0
@@ -159,13 +189,14 @@ function handleAdd(cls, id, dataStr) {
159
189
  if (cls === "mistake") upsertMistake(record)
160
190
  else writeObject(cls, record)
161
191
 
162
- const action = existing ? "updated" : "saved"
192
+ const action = existingIsActive ? "updated" : "saved"
163
193
  return `${action}: ${id.trim()}`
164
194
  }
165
195
 
166
196
  function handleFetch(cls, id) {
167
197
  if (!id?.trim()) return "non-blank id is required"
168
- const record = queryGet(cls, id.trim())
198
+ id = sanitizeId(id.trim())
199
+ const record = queryGet(cls, id)
169
200
  if (!record) return `not found: ${id}`
170
201
  return stableStringify(record)
171
202
  }
@@ -190,35 +221,46 @@ function handleSearch(query, scope, classes, project, limit) {
190
221
  if (!q) return "non-blank query is required"
191
222
  const clsList = Array.isArray(classes) && classes.length ? classes : CLASSES
192
223
  const lim = Math.min(limit ?? 10, 50)
193
- let records = []
224
+ let candidates = []
194
225
  for (const cls of clsList) {
195
226
  if (cls === "mistake") {
196
227
  for (const m of readJsonl(path.join(classDir(cls), "mistakes.jsonl"))) {
197
- if (!hasExpired(m)) records.push(m)
228
+ if (!hasExpired(m)) candidates.push({ ...m, _cls: cls })
198
229
  }
199
230
  } else {
200
231
  const dir = classDir(cls)
201
- let files = []
202
- try { files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && f !== "index.json") } catch { continue }
203
- for (const f of files) {
204
- const r = readJson(path.join(dir, f), null)
205
- if (r && !hasExpired(r)) records.push(r)
232
+ const index = readJson(path.join(dir, "index.json"), [])
233
+ if (!Array.isArray(index)) continue
234
+ for (const entry of index) {
235
+ if (!hasExpired(entry)) candidates.push({ ...entry, _cls: cls })
206
236
  }
207
237
  }
208
238
  }
209
- if (scope === "global") records = records.filter(r => r.scope === "global" || !r.scope)
210
- else if (scope === "local") records = records.filter(r => r.scope === "project" || r.scope === "session")
211
- const scored = records.map(r => ({ ...buildEntry(r.class || "verification_receipt", r), score: scoreRelevance(r, q, project || "") }))
239
+ if (scope === "global") candidates = candidates.filter(r => r.scope === "global" || !r.scope)
240
+ else if (scope === "local") candidates = candidates.filter(r => r.scope === "project" || r.scope === "session")
241
+
242
+ // Score using index entries (summary-based)
243
+ const scored = candidates
244
+ .map(e => ({ ...e, score: scoreRelevance(e, q, project || "") }))
212
245
  .filter(e => e.score > 0)
213
246
  .sort((a, b) => b.score - a.score)
214
247
  .slice(0, lim)
215
- const lines = scored.map(e => ` ${e.id} (${e.score}pt): ${truncateText(e.summary || "", 80)}`)
216
- return `${scored.length} result${scored.length === 1 ? "" : "s"} for '${q}'\n` + lines.join("\n")
248
+
249
+ // Fetch full records for top results to get richer fields
250
+ const results = scored.map(e => {
251
+ if (e._cls === "mistake") return e
252
+ const full = queryGet(e._cls, e.id)
253
+ return full || e
254
+ })
255
+
256
+ const lines = results.map(e => ` ${e.id} (${e.score}pt): ${truncateText(e.summary || "", 80)}`)
257
+ return `${results.length} result${results.length === 1 ? "" : "s"} for '${q}'\n` + lines.join("\n")
217
258
  }
218
259
 
219
260
  function handleArchive(cls, id) {
220
261
  if (!id?.trim()) return "non-blank id is required"
221
- const record = queryGet(cls, id.trim())
262
+ id = sanitizeId(id.trim())
263
+ const record = queryGet(cls, id)
222
264
  if (!record) return `not found: ${id}`
223
265
  record.status = "expired"
224
266
  record.updated_at = new Date().toISOString()
@@ -227,13 +269,15 @@ function handleArchive(cls, id) {
227
269
  return `archived: ${id.trim()}`
228
270
  }
229
271
 
230
- export const MemoryToolsPlugin = async () => {
272
+ export const MemoryToolsPlugin = async (ctx) => {
231
273
  fs.mkdirSync(MEMORY_DIR, { recursive: true })
232
274
  fs.mkdirSync(getRuntimeRoot(), { recursive: true })
233
275
 
276
+ const config = loadConfig()
277
+
234
278
  return {
235
279
  tool: {
236
- add_memory: tool({
280
+ ohc_save: tool({
237
281
  description: "Save a new memory record or update an existing one by class and id",
238
282
  args: {
239
283
  class: tool.schema.enum(CLASSES),
@@ -242,11 +286,16 @@ export const MemoryToolsPlugin = async () => {
242
286
  },
243
287
  async execute(args, context) {
244
288
  setToolTitle(context, `save ${args.class}: ${args.id}`)
245
- return handleAdd(args.class, args.id, args.data)
289
+ const result = handleAdd(args.class, args.id, args.data)
290
+ if (result.startsWith("saved:") || result.startsWith("updated:")) {
291
+ const action = result.startsWith("saved:") ? "Saved" : "Updated"
292
+ sendMemoryNotification(ctx?.client, context.sessionID, config, action, args.class, args.id, null).catch(() => {})
293
+ }
294
+ return result
246
295
  },
247
296
  }),
248
297
 
249
- fetch_memory: tool({
298
+ ohc_get: tool({
250
299
  description: "Get a specific memory record by class and id",
251
300
  args: {
252
301
  class: tool.schema.enum(CLASSES).describe("Memory class"),
@@ -258,7 +307,7 @@ export const MemoryToolsPlugin = async () => {
258
307
  },
259
308
  }),
260
309
 
261
- list_memory: tool({
310
+ ohc_list: tool({
262
311
  description: "List recent memory records by class, sorted by recency",
263
312
  args: {
264
313
  class: tool.schema.enum(CLASSES).describe("Memory class"),
@@ -270,7 +319,7 @@ export const MemoryToolsPlugin = async () => {
270
319
  },
271
320
  }),
272
321
 
273
- latest_memory: tool({
322
+ ohc_latest: tool({
274
323
  description: "Get the latest active memory record by class",
275
324
  args: {
276
325
  class: tool.schema.enum(CLASSES).describe("Memory class"),
@@ -281,7 +330,7 @@ export const MemoryToolsPlugin = async () => {
281
330
  },
282
331
  }),
283
332
 
284
- search_memory: tool({
333
+ ohc_search: tool({
285
334
  description: "Search memory records with keyword matching and relevance ranking across all classes",
286
335
  args: {
287
336
  query: tool.schema.string().describe("Search query string"),
@@ -296,7 +345,7 @@ export const MemoryToolsPlugin = async () => {
296
345
  },
297
346
  }),
298
347
 
299
- archive_memory: tool({
348
+ ohc_archive: tool({
300
349
  description: "Soft-delete a memory record by setting its status to expired",
301
350
  args: {
302
351
  class: tool.schema.enum(CLASSES),
@@ -304,7 +353,11 @@ export const MemoryToolsPlugin = async () => {
304
353
  },
305
354
  async execute(args, context) {
306
355
  setToolTitle(context, `archive ${args.class}: ${args.id}`)
307
- return handleArchive(args.class, args.id)
356
+ const result = handleArchive(args.class, args.id)
357
+ if (result.startsWith("archived:")) {
358
+ sendMemoryNotification(ctx?.client, context.sessionID, config, "Archived", args.class, args.id, null).catch(() => {})
359
+ }
360
+ return result
308
361
  },
309
362
  }),
310
363
  },
@@ -0,0 +1,69 @@
1
+ export function syncCompressionBlocks(state, messages) {
2
+ const ms = state.prune.messages
3
+ if (!ms?.blocksById?.size) return
4
+
5
+ const messageIds = new Set()
6
+ for (const msg of messages) {
7
+ if (msg.info?.id) messageIds.add(msg.info.id)
8
+ }
9
+
10
+ const prevActive = new Set(ms.activeBlockIds)
11
+
12
+ ms.activeBlockIds.clear()
13
+ ms.activeByAnchorMessageId?.clear()
14
+
15
+ const now = Date.now()
16
+ const ordered = [...ms.blocksById.values()].sort(
17
+ (a, b) => (a.createdAt || 0) - (b.createdAt || 0) || a.blockId - b.blockId,
18
+ )
19
+
20
+ for (const block of ordered) {
21
+ const hasOrigin = typeof block.compressMessageId === "string" &&
22
+ block.compressMessageId.length > 0 &&
23
+ messageIds.has(block.compressMessageId)
24
+
25
+ if (!hasOrigin) {
26
+ block.active = false
27
+ block.deactivatedAt = now
28
+ continue
29
+ }
30
+
31
+ if (block.deactivatedByUser) {
32
+ block.active = false
33
+ if (block.deactivatedAt === undefined) block.deactivatedAt = now
34
+ continue
35
+ }
36
+
37
+ const consumed = Array.isArray(block.consumedBlockIds) ? block.consumedBlockIds : []
38
+ for (const cid of consumed) {
39
+ if (!ms.activeBlockIds.has(cid)) continue
40
+ const cb = ms.blocksById.get(cid)
41
+ if (cb) {
42
+ cb.active = false
43
+ cb.deactivatedAt = now
44
+ cb.deactivatedByBlockId = block.blockId
45
+ }
46
+ ms.activeBlockIds.delete(cid)
47
+ }
48
+
49
+ block.active = true
50
+ block.deactivatedAt = undefined
51
+ block.deactivatedByBlockId = undefined
52
+ ms.activeBlockIds.add(block.blockId)
53
+ }
54
+
55
+ for (const entry of ms.byMessageId.values()) {
56
+ const all = Array.isArray(entry.allBlockIds)
57
+ ? [...new Set(entry.allBlockIds.filter(id => Number.isInteger(id) && id > 0))]
58
+ : []
59
+ entry.allBlockIds = all
60
+ entry.activeBlockIds = all.filter(id => ms.activeBlockIds.has(id))
61
+ }
62
+
63
+ let deactivated = 0
64
+ let reactivated = 0
65
+ for (const id of prevActive) { if (!ms.activeBlockIds.has(id)) deactivated++ }
66
+ for (const id of ms.activeBlockIds) { if (!prevActive.has(id)) reactivated++ }
67
+
68
+ return { deactivated, reactivated }
69
+ }
@@ -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
+ }