memory-event.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Memory event types, ID generation, validation, and secret detection. |
| 3 | * Phase 8 Memory Augmentation. |
| 4 | */ |
| 5 | |
| 6 | import crypto from 'crypto'; |
| 7 | |
| 8 | export const MEMORY_EVENT_TYPES = Object.freeze([ |
| 9 | 'search', |
| 10 | 'export', |
| 11 | 'write', |
| 12 | 'import', |
| 13 | 'index', |
| 14 | 'propose', |
| 15 | 'agent_interaction', |
| 16 | 'capture', |
| 17 | 'error', |
| 18 | 'session_summary', |
| 19 | 'user', |
| 20 | 'consolidation', |
| 21 | 'consolidation_pass', |
| 22 | 'maintenance', |
| 23 | 'insight', |
| 24 | ]); |
| 25 | |
| 26 | export const DEFAULT_CAPTURE_TYPES = Object.freeze([ |
| 27 | 'search', |
| 28 | 'export', |
| 29 | 'write', |
| 30 | 'import', |
| 31 | 'index', |
| 32 | 'propose', |
| 33 | ]); |
| 34 | |
| 35 | const SENSITIVE_VALUE = /(api[_-]?key|secret|password|token|credential|authorization|bearer|private[_-]?key)/i; |
| 36 | |
| 37 | /** |
| 38 | * Generate a memory event ID: mem_ + 12 hex chars. |
| 39 | * @returns {string} |
| 40 | */ |
| 41 | export function generateMemoryId() { |
| 42 | return 'mem_' + crypto.randomBytes(6).toString('hex'); |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Check if a string value likely contains secrets. |
| 47 | * @param {string} str |
| 48 | * @returns {boolean} |
| 49 | */ |
| 50 | export function containsSensitivePattern(str) { |
| 51 | if (typeof str !== 'string') return false; |
| 52 | return SENSITIVE_VALUE.test(str); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Recursively scan an object for keys that match secret patterns. |
| 57 | * @param {unknown} obj |
| 58 | * @param {number} depth |
| 59 | * @returns {boolean} |
| 60 | */ |
| 61 | export function hasSensitiveKeys(obj, depth = 0) { |
| 62 | if (depth > 8 || obj == null || typeof obj !== 'object') return false; |
| 63 | if (Array.isArray(obj)) return obj.some((v) => hasSensitiveKeys(v, depth + 1)); |
| 64 | for (const [k, v] of Object.entries(obj)) { |
| 65 | if (SENSITIVE_VALUE.test(k)) return true; |
| 66 | if (typeof v === 'object' && v !== null && hasSensitiveKeys(v, depth + 1)) return true; |
| 67 | } |
| 68 | return false; |
| 69 | } |
| 70 | |
| 71 | /** @type {readonly string[]} Valid values for the event status field. */ |
| 72 | export const MEMORY_EVENT_STATUSES = Object.freeze(['success', 'failed']); |
| 73 | |
| 74 | /** |
| 75 | * Create a validated memory event object. |
| 76 | * @param {string} type - Event type (must be in MEMORY_EVENT_TYPES) |
| 77 | * @param {object} data - Event payload |
| 78 | * @param {{ vaultId?: string, ttl?: string|null, airId?: string, status?: 'success'|'failed' }} [opts] |
| 79 | * @returns {{ id: string, type: string, ts: string, vault_id: string, data: object, status: string, ttl: string|null, air_id?: string }} |
| 80 | * @throws if type is invalid or data contains secrets |
| 81 | */ |
| 82 | export function createMemoryEvent(type, data, opts = {}) { |
| 83 | if (!MEMORY_EVENT_TYPES.includes(type)) { |
| 84 | throw new Error(`Invalid memory event type: "${type}". Valid: ${MEMORY_EVENT_TYPES.join(', ')}`); |
| 85 | } |
| 86 | if (data == null || typeof data !== 'object') { |
| 87 | throw new Error('Memory event data must be a non-null object.'); |
| 88 | } |
| 89 | if (hasSensitiveKeys(data)) { |
| 90 | throw new Error('Memory event data contains sensitive key patterns. Remove secrets before storing.'); |
| 91 | } |
| 92 | const status = opts.status || 'success'; |
| 93 | if (!MEMORY_EVENT_STATUSES.includes(status)) { |
| 94 | throw new Error(`Invalid memory event status: "${status}". Valid: ${MEMORY_EVENT_STATUSES.join(', ')}`); |
| 95 | } |
| 96 | const event = { |
| 97 | id: generateMemoryId(), |
| 98 | type, |
| 99 | ts: new Date().toISOString(), |
| 100 | vault_id: opts.vaultId || 'default', |
| 101 | data, |
| 102 | status, |
| 103 | ttl: opts.ttl || null, |
| 104 | }; |
| 105 | if (opts.airId) event.air_id = opts.airId; |
| 106 | return event; |
| 107 | } |
| 108 | |
| 109 | const STOP_WORDS = new Set([ |
| 110 | 'a', 'an', 'the', 'is', 'it', 'of', 'to', 'in', 'on', 'for', 'with', |
| 111 | 'and', 'or', 'but', 'at', 'by', 'from', 'as', 'was', 'were', 'be', |
| 112 | 'been', 'has', 'had', 'do', 'did', 'will', 'can', 'may', 'not', 'no', |
| 113 | 'all', 'each', 'if', 'so', 'this', 'that', 'my', 'your', 'its', 'our', |
| 114 | ]); |
| 115 | |
| 116 | /** |
| 117 | * Normalize a string into a URL-safe slug: lowercase, alphanumeric + hyphens, |
| 118 | * no leading/trailing hyphens, collapsed runs. |
| 119 | * @param {string} raw |
| 120 | * @returns {string} |
| 121 | */ |
| 122 | export function slugify(raw) { |
| 123 | return String(raw) |
| 124 | .toLowerCase() |
| 125 | .replace(/[^a-z0-9]+/g, '-') |
| 126 | .replace(/^-+|-+$/g, '') |
| 127 | .slice(0, 64); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Extract a lightweight topic slug from a memory event using heuristic rules. |
| 132 | * |
| 133 | * Strategy (first match wins): |
| 134 | * 1. data.topic — explicit topic override |
| 135 | * 2. data.path / data.paths[0] — derive from the first directory component |
| 136 | * 3. data.query — pick the most significant keyword(s) |
| 137 | * 4. data.source / data.source_type — use as topic |
| 138 | * 5. data.key — use as topic (for user-defined entries) |
| 139 | * 6. Fall back to the event type itself |
| 140 | * |
| 141 | * @param {object} event — a memory event (needs .type and .data) |
| 142 | * @returns {string} topic slug (lowercase, hyphenated, max 64 chars) |
| 143 | */ |
| 144 | export function extractTopicFromEvent(event) { |
| 145 | if (!event || typeof event !== 'object') return 'unknown'; |
| 146 | const data = event.data; |
| 147 | if (!data || typeof data !== 'object') return slugify(event.type || 'unknown'); |
| 148 | |
| 149 | if (typeof data.topic === 'string' && data.topic.trim()) { |
| 150 | return slugify(data.topic); |
| 151 | } |
| 152 | |
| 153 | const refPath = typeof data.path === 'string' ? data.path |
| 154 | : (Array.isArray(data.paths) && typeof data.paths[0] === 'string') ? data.paths[0] |
| 155 | : null; |
| 156 | if (refPath) { |
| 157 | const segments = refPath.replace(/\\/g, '/').split('/').filter(Boolean); |
| 158 | if (segments.length > 1) { |
| 159 | return slugify(segments[0]); |
| 160 | } |
| 161 | const stem = segments[0]?.replace(/\.md$/i, ''); |
| 162 | if (stem) return slugify(stem); |
| 163 | } |
| 164 | |
| 165 | if (typeof data.query === 'string' && data.query.trim()) { |
| 166 | const words = data.query |
| 167 | .toLowerCase() |
| 168 | .replace(/[^a-z0-9\s]/g, ' ') |
| 169 | .split(/\s+/) |
| 170 | .filter((w) => w.length > 1 && !STOP_WORDS.has(w)); |
| 171 | if (words.length > 0) { |
| 172 | return slugify(words.slice(0, 3).join('-')); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | if (typeof data.source === 'string' && data.source.trim()) return slugify(data.source); |
| 177 | if (typeof data.source_type === 'string' && data.source_type.trim()) return slugify(data.source_type); |
| 178 | if (typeof data.key === 'string' && data.key.trim()) return slugify(data.key); |
| 179 | if (typeof data.format === 'string' && data.format.trim()) return slugify(`export-${data.format}`); |
| 180 | |
| 181 | return slugify(event.type || 'unknown'); |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Validate a memory event object read from storage. |
| 186 | * Accepts events with or without the status field (backward compat). |
| 187 | * @param {object} event |
| 188 | * @returns {boolean} |
| 189 | */ |
| 190 | export function isValidMemoryEvent(event) { |
| 191 | if ( |
| 192 | event == null || |
| 193 | typeof event !== 'object' || |
| 194 | typeof event.id !== 'string' || |
| 195 | !event.id.startsWith('mem_') || |
| 196 | typeof event.type !== 'string' || |
| 197 | typeof event.ts !== 'string' || |
| 198 | typeof event.vault_id !== 'string' || |
| 199 | event.data == null || |
| 200 | typeof event.data !== 'object' |
| 201 | ) { |
| 202 | return false; |
| 203 | } |
| 204 | if (event.status != null && !MEMORY_EVENT_STATUSES.includes(event.status)) { |
| 205 | return false; |
| 206 | } |
| 207 | return true; |
| 208 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago