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,176 @@
1
+ // OpenHermes Agent Handoff — lightweight helper library
2
+ // Convention-based, no runtime deps. Agents import and call.
3
+
4
+ let _idSeq = 0
5
+ const COMPLEXITY_RULES = [
6
+ { maxFiles: 2, patterns: 0, label: "easy" },
7
+ { maxFiles: 10, patterns: 0, label: "medium" },
8
+ { maxFiles: 60, patterns: 0, label: "hard" },
9
+ ]
10
+
11
+ function nextId() {
12
+ _idSeq++
13
+ return `ho_${Date.now().toString(36)}_${_idSeq}`
14
+ }
15
+
16
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
17
+
18
+ // Estimate task complexity from file count and description.
19
+ export function assessComplexity(fileCount = 1, description = "", patterns = []) {
20
+ const pScore = Array.isArray(patterns) ? patterns.length : 0
21
+ const level = fileCount > 50 || pScore > 5 ? "very-large"
22
+ : fileCount > 10 || pScore > 2 ? "hard"
23
+ : fileCount > 2 || pScore > 0 ? "medium"
24
+ : "easy"
25
+ return {
26
+ level,
27
+ fileCount: clamp(fileCount, 0, 9999),
28
+ patterns: pScore,
29
+ strategy: level === "easy" ? "direct"
30
+ : level === "medium" ? "sequential-or-fan-out"
31
+ : level === "hard" ? "sequential-multi"
32
+ : "fan-out"
33
+ }
34
+ }
35
+
36
+ // Build a structured handoff request string for the `task` tool prompt.
37
+ export function handoffRequest(opts) {
38
+ const {
39
+ agent,
40
+ phase = "execute",
41
+ context = "",
42
+ goal = "",
43
+ expected = "",
44
+ permissions = "",
45
+ limits = "",
46
+ complexity,
47
+ } = opts || {}
48
+ const id = nextId()
49
+ const c = complexity || assessComplexity()
50
+ const lines = [
51
+ `## HANDOFF REQUEST`,
52
+ `Agent: ${agent}`,
53
+ `Task ID: ${id}`,
54
+ `Phase: ${phase}`,
55
+ `Complexity: ${c.level}`,
56
+ ``,
57
+ `### Context`,
58
+ String(context).trim() || "(no context provided)",
59
+ ``,
60
+ `### Goal`,
61
+ String(goal).trim() || "(no goal provided)",
62
+ ``,
63
+ `### Expected Output`,
64
+ String(expected).trim() || "Return structured result with status, summary, details, receipts, next, learning.",
65
+ ``,
66
+ `### Permissions`,
67
+ String(permissions).trim() || "(default permissions apply)",
68
+ ``,
69
+ `### Limits`,
70
+ String(limits).trim() || "(no explicit limits)",
71
+ ``,
72
+ `### Handoff Result Format`,
73
+ `When done, return:`,
74
+ `## HANDOFF RESULT`,
75
+ `Status: success | failure | partial`,
76
+ `Task ID: ${id}`,
77
+ `### Summary`,
78
+ `### Details`,
79
+ `### Receipts`,
80
+ `### Next`,
81
+ `### Learning`,
82
+ ].join("\n")
83
+ return { id, prompt: lines }
84
+ }
85
+
86
+ // Validate a subagent's result string has required sections.
87
+ export function parseHandoffResult(text) {
88
+ if (!text || typeof text !== "string") return { status: "invalid", error: "no result text" }
89
+ const statusMatch = text.match(/Status:\s*(success|failure|partial)/i)
90
+ const hasSummary = /### Summary/i.test(text)
91
+ const hasDetails = /### Details/i.test(text)
92
+ const hasReceipts = /### Receipts/i.test(text)
93
+ const summaryMatch = text.match(/### Summary\s*\n\s*(.+)/i)
94
+ return {
95
+ status: statusMatch ? statusMatch[1].toLowerCase() : "unknown",
96
+ summary: summaryMatch ? summaryMatch[1].trim() : "",
97
+ hasSummary,
98
+ hasDetails,
99
+ hasReceipts,
100
+ valid: !!(statusMatch && hasSummary),
101
+ }
102
+ }
103
+
104
+ // Agent capability/role definitions for reference.
105
+ export const AGENT_ROLES = {
106
+ OpenHermes: { tier: 3, edit: true, exec: true, desc: "Primary agent — all tools, all permissions" },
107
+ architect: { tier: 1, edit: false, exec: false, desc: "System architecture design" },
108
+ planner: { tier: 1, edit: false, exec: false, desc: "Feature/refactor planning" },
109
+ "code-reviewer": { tier: 1, edit: false, exec: false, desc: "Code quality review" },
110
+ "security-reviewer": { tier: 1, edit: false, exec: false, desc: "Security audit (report only, no patches)" },
111
+ explore: { tier: 1, edit: false, exec: false, desc: "Read-only codebase exploration" },
112
+ "build-error-resolver": { tier: 2, edit: true, exec: true, desc: "Build/type error fixes" },
113
+ "doc-updater": { tier: 2, edit: true, exec: true, desc: "Doc/codemap updates" },
114
+ "refactor-cleaner": { tier: 2, edit: true, exec: true, desc: "Dead code cleanup" },
115
+ "tdd-guide": { tier: 2, edit: true, exec: true, desc: "TDD red-green-refactor" },
116
+ "loop-operator": { tier: 3, edit: true, exec: true, desc: "Managed autonomous loops" },
117
+ "e2e-runner": { tier: 3, edit: true, exec: true, desc: "Playwright E2E tests" },
118
+ "docs-lookup": { tier: 1, edit: false, exec: true, desc: "MCP doc lookup" },
119
+ "harness-optimizer": { tier: 1, edit: false, exec: true, desc: "Harness config audit" },
120
+ "review-database": { tier: 1, edit: false, exec: true, desc: "PostgreSQL review" },
121
+ "review-go": { tier: 1, edit: false, exec: true, desc: "Go code review" },
122
+ "build-go": { tier: 2, edit: true, exec: true, desc: "Go build fix" },
123
+ "review-java": { tier: 1, edit: false, exec: true, desc: "Java review" },
124
+ "build-java": { tier: 2, edit: true, exec: true, desc: "Java build fix" },
125
+ "review-kotlin": { tier: 1, edit: false, exec: true, desc: "Kotlin review" },
126
+ "build-kotlin": { tier: 2, edit: true, exec: true, desc: "Kotlin build fix" },
127
+ "review-cpp": { tier: 1, edit: false, exec: true, desc: "C++ review" },
128
+ "build-cpp": { tier: 2, edit: true, exec: true, desc: "C++ build fix" },
129
+ "review-rust": { tier: 1, edit: false, exec: true, desc: "Rust review" },
130
+ "build-rust": { tier: 2, edit: true, exec: true, desc: "Rust build fix" },
131
+ "review-python": { tier: 1, edit: false, exec: true, desc: "Python review" },
132
+ "general": { tier: 2, edit: true, exec: true, desc: "General purpose" },
133
+ }
134
+
135
+ // Suggest best agent for a task type.
136
+ export function suggestAgent(taskType) {
137
+ const map = {
138
+ architecture: "architect",
139
+ plan: "planner",
140
+ "code-review": "code-reviewer",
141
+ security: "security-reviewer",
142
+ explore: "explore",
143
+ "build-fix": "build-error-resolver",
144
+ docs: "doc-updater",
145
+ "dead-code": "refactor-cleaner",
146
+ tdd: "tdd-guide",
147
+ e2e: "e2e-runner",
148
+ "doc-lookup": "docs-lookup",
149
+ "db-review": "review-database",
150
+ "go-review": "review-go",
151
+ "go-build": "build-go",
152
+ "java-review": "review-java",
153
+ "java-build": "build-java",
154
+ "kotlin-review": "review-kotlin",
155
+ "kotlin-build": "build-kotlin",
156
+ "cpp-review": "review-cpp",
157
+ "cpp-build": "build-cpp",
158
+ "rust-review": "review-rust",
159
+ "rust-build": "build-rust",
160
+ "python-review": "review-python",
161
+ loop: "loop-operator",
162
+ "harness-audit": "harness-optimizer",
163
+ }
164
+ return map[taskType] || null
165
+ }
166
+
167
+ // Quick permission check: can agent X do action Y?
168
+ export function canAgent(agentName, action) {
169
+ const role = AGENT_ROLES[agentName]
170
+ if (!role) return false
171
+ if (action === "edit") return role.edit
172
+ if (action === "exec" || action === "bash") return role.exec
173
+ if (action === "read") return true
174
+ if (action === "delegate") return role.tier >= 1
175
+ return false
176
+ }
package/lib/hardening.mjs CHANGED
@@ -96,13 +96,8 @@ function atomicWriteJson(filePath, data) {
96
96
  try {
97
97
  fs.renameSync(tmpPath, filePath)
98
98
  } catch (err) {
99
- try { if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true }) } catch {}
100
- try {
101
- fs.renameSync(tmpPath, filePath)
102
- } catch (retryErr) {
103
- try { fs.rmSync(tmpPath, { force: true }) } catch {}
104
- throw retryErr
105
- }
99
+ fs.copyFileSync(tmpPath, filePath)
100
+ fs.rmSync(tmpPath, { force: true })
106
101
  }
107
102
  }
108
103
 
@@ -120,4 +115,14 @@ function readJsonl(fp) {
120
115
  } catch { return [] }
121
116
  }
122
117
 
123
- export { atomicWriteJson, fingerprintEnvironment, fingerprintFile, isTruthy, readJson, readJsonl, redactSensitiveText, sanitizeRecord, truncateText }
118
+ function buildEnvironmentFingerprint(root, directory, project) {
119
+ return fingerprintEnvironment({
120
+ cwd: directory,
121
+ harnessRoot: root,
122
+ projectRoot: directory,
123
+ project: project?.name || path.basename(directory),
124
+ sessionId: project?.session_id || null,
125
+ })
126
+ }
127
+
128
+ export { atomicWriteJson, buildEnvironmentFingerprint, fingerprintEnvironment, fingerprintFile, isPlainObject, isTruthy, readJson, readJsonl, redactSensitiveText, sanitizeRecord, truncateText }
@@ -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
+ }