memory-event.mjs
208 lines 6.4 KB
Raw
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