clawvault 2.5.4 → 2.6.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 (82) hide show
  1. package/README.md +159 -159
  2. package/bin/clawvault.js +111 -111
  3. package/bin/command-registration.test.js +166 -166
  4. package/bin/command-runtime.js +93 -93
  5. package/bin/command-runtime.test.js +154 -154
  6. package/bin/help-contract.test.js +39 -39
  7. package/bin/register-config-commands.js +153 -153
  8. package/bin/register-config-route-commands.test.js +121 -121
  9. package/bin/register-core-commands.js +237 -237
  10. package/bin/register-kanban-commands.js +56 -56
  11. package/bin/register-kanban-commands.test.js +83 -83
  12. package/bin/register-maintenance-commands.js +282 -282
  13. package/bin/register-project-commands.js +209 -209
  14. package/bin/register-project-commands.test.js +206 -206
  15. package/bin/register-query-commands.js +317 -317
  16. package/bin/register-query-commands.test.js +65 -65
  17. package/bin/register-resilience-commands.js +182 -182
  18. package/bin/register-resilience-commands.test.js +81 -81
  19. package/bin/register-route-commands.js +114 -114
  20. package/bin/register-session-lifecycle-commands.js +206 -206
  21. package/bin/register-tailscale-commands.js +106 -106
  22. package/bin/register-task-commands.js +348 -348
  23. package/bin/register-task-commands.test.js +69 -69
  24. package/bin/register-template-commands.js +75 -72
  25. package/bin/register-template-commands.test.js +87 -0
  26. package/bin/register-vault-operations-commands.js +300 -300
  27. package/bin/test-helpers/cli-command-fixtures.js +119 -119
  28. package/dashboard/lib/graph-diff.js +104 -104
  29. package/dashboard/lib/graph-diff.test.js +75 -75
  30. package/dashboard/lib/vault-parser.js +556 -556
  31. package/dashboard/lib/vault-parser.test.js +254 -254
  32. package/dashboard/public/app.js +796 -796
  33. package/dashboard/public/index.html +52 -52
  34. package/dashboard/public/styles.css +221 -221
  35. package/dashboard/server.js +374 -374
  36. package/dist/{chunk-J5EMBUPK.js → chunk-4OXMU5S2.js} +1 -1
  37. package/dist/{chunk-4IV3R2F5.js → chunk-4TE4JMLA.js} +1 -1
  38. package/dist/{chunk-5GZFTAL7.js → chunk-AZYOKJYC.js} +128 -42
  39. package/dist/{chunk-JDLOL2PL.js → chunk-HA5M6KJB.js} +1 -1
  40. package/dist/{chunk-IZEY5S74.js → chunk-IEVLHNLU.js} +1 -1
  41. package/dist/{chunk-OSMS7QIG.js → chunk-ME37YNW3.js} +2 -2
  42. package/dist/chunk-MFAWT5O5.js +301 -0
  43. package/dist/{chunk-TPDH3JPP.js → chunk-PBEE567J.js} +1 -1
  44. package/dist/{chunk-S2IG7VNM.js → chunk-Q2J5YTUF.js} +2 -2
  45. package/dist/{chunk-IOALNTAN.js → chunk-QWQ3TIKS.js} +103 -29
  46. package/dist/{chunk-YCVDVI5B.js → chunk-R2MIW5G7.js} +1 -1
  47. package/dist/{chunk-W2HNZC22.js → chunk-UEOUADMO.js} +1 -1
  48. package/dist/cli/index.js +8 -6
  49. package/dist/commands/backlog.js +3 -1
  50. package/dist/commands/blocked.js +3 -1
  51. package/dist/commands/canvas.js +3 -1
  52. package/dist/commands/doctor.js +7 -5
  53. package/dist/commands/kanban.js +4 -2
  54. package/dist/commands/observe.js +7 -5
  55. package/dist/commands/project.js +5 -3
  56. package/dist/commands/rebuild.js +6 -4
  57. package/dist/commands/replay.js +6 -4
  58. package/dist/commands/setup.js +1 -1
  59. package/dist/commands/sleep.js +5 -3
  60. package/dist/commands/status.js +6 -4
  61. package/dist/commands/task.js +4 -2
  62. package/dist/commands/template.d.ts +10 -1
  63. package/dist/commands/template.js +47 -55
  64. package/dist/index.js +16 -15
  65. package/dist/lib/project-utils.js +4 -2
  66. package/dist/lib/task-utils.d.ts +14 -13
  67. package/dist/lib/task-utils.js +3 -1
  68. package/dist/lib/template-engine.d.ts +1 -0
  69. package/hooks/clawvault/HOOK.md +83 -83
  70. package/hooks/clawvault/handler.js +816 -816
  71. package/hooks/clawvault/handler.test.js +263 -263
  72. package/package.json +94 -94
  73. package/templates/checkpoint.md +34 -19
  74. package/templates/daily-note.md +34 -19
  75. package/templates/daily.md +34 -19
  76. package/templates/decision.md +39 -17
  77. package/templates/handoff.md +34 -19
  78. package/templates/lesson.md +31 -16
  79. package/templates/person.md +37 -19
  80. package/templates/project.md +84 -23
  81. package/templates/task.md +81 -0
  82. /package/dist/{chunk-AXKYDCNN.js → chunk-RVYA52PY.js} +0 -0
@@ -1,816 +1,816 @@
1
- /**
2
- * ClawVault OpenClaw Hook
3
- *
4
- * Provides automatic context death resilience:
5
- * - gateway:startup → detect context death, inject recovery info
6
- * - gateway:heartbeat → cheap active-session threshold checks
7
- * - command:new → auto-checkpoint before session reset
8
- * - compaction:memoryFlush → force active-session flush before compaction
9
- * - session:start → inject relevant context for first user prompt
10
- *
11
- * SECURITY: Uses execFileSync (no shell) to prevent command injection
12
- */
13
-
14
- import { execFileSync } from 'child_process';
15
- import * as fs from 'fs';
16
- import * as os from 'os';
17
- import * as path from 'path';
18
-
19
- const MAX_CONTEXT_RESULTS = 4;
20
- const MAX_CONTEXT_PROMPT_LENGTH = 500;
21
- const MAX_CONTEXT_SNIPPET_LENGTH = 220;
22
- const MAX_RECAP_RESULTS = 6;
23
- const MAX_RECAP_SNIPPET_LENGTH = 220;
24
- const EVENT_NAME_SEPARATOR_RE = /[.:/]/g;
25
- const OBSERVE_CURSOR_FILE = 'observe-cursors.json';
26
- const ONE_KIB = 1024;
27
- const ONE_MIB = ONE_KIB * ONE_KIB;
28
- const SMALL_SESSION_THRESHOLD_BYTES = 50 * ONE_KIB;
29
- const MEDIUM_SESSION_THRESHOLD_BYTES = 150 * ONE_KIB;
30
- const LARGE_SESSION_THRESHOLD_BYTES = 300 * ONE_KIB;
31
-
32
- // Sanitize string for safe display (prevent prompt injection via control chars)
33
- function sanitizeForDisplay(str) {
34
- if (typeof str !== 'string') return '';
35
- // Remove control characters, limit length, escape markdown
36
- return str
37
- .replace(/[\x00-\x1f\x7f]/g, '') // Remove control chars
38
- .replace(/[`*_~\[\]]/g, '\\$&') // Escape markdown
39
- .slice(0, 200); // Limit length
40
- }
41
-
42
- // Sanitize prompt before passing to CLI command
43
- function sanitizePromptForContext(str) {
44
- if (typeof str !== 'string') return '';
45
- return str
46
- .replace(/[\x00-\x1f\x7f]/g, ' ')
47
- .replace(/\s+/g, ' ')
48
- .trim()
49
- .slice(0, MAX_CONTEXT_PROMPT_LENGTH);
50
- }
51
-
52
- function sanitizeSessionKey(str) {
53
- if (typeof str !== 'string') return '';
54
- const trimmed = str.trim();
55
- if (!/^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/.test(trimmed)) {
56
- return '';
57
- }
58
- return trimmed.slice(0, 200);
59
- }
60
-
61
- function extractSessionKey(event) {
62
- const candidates = [
63
- event?.sessionKey,
64
- event?.context?.sessionKey,
65
- event?.session?.key,
66
- event?.context?.session?.key,
67
- event?.metadata?.sessionKey
68
- ];
69
-
70
- for (const candidate of candidates) {
71
- const key = sanitizeSessionKey(candidate);
72
- if (key) return key;
73
- }
74
-
75
- return '';
76
- }
77
-
78
- function extractAgentIdFromSessionKey(sessionKey) {
79
- const match = /^agent:([^:]+):/.exec(sessionKey);
80
- if (!match?.[1]) return '';
81
- const agentId = match[1].trim();
82
- if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentId)) return '';
83
- return agentId;
84
- }
85
-
86
- function sanitizeAgentId(agentId) {
87
- if (typeof agentId !== 'string') return '';
88
- const normalized = agentId.trim();
89
- if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalized)) return '';
90
- return normalized;
91
- }
92
-
93
- function normalizeAbsoluteEnvPath(value) {
94
- if (typeof value !== 'string') return null;
95
- const trimmed = value.trim();
96
- if (!trimmed) return null;
97
- const resolved = path.resolve(trimmed);
98
- if (!path.isAbsolute(resolved)) return null;
99
- return resolved;
100
- }
101
-
102
- function getOpenClawAgentsDir() {
103
- const stateDir = normalizeAbsoluteEnvPath(process.env.OPENCLAW_STATE_DIR);
104
- if (stateDir) {
105
- return path.join(stateDir, 'agents');
106
- }
107
-
108
- const openClawHome = normalizeAbsoluteEnvPath(process.env.OPENCLAW_HOME);
109
- if (openClawHome) {
110
- return path.join(openClawHome, 'agents');
111
- }
112
-
113
- return path.join(os.homedir(), '.openclaw', 'agents');
114
- }
115
-
116
- function getObserveCursorPath(vaultPath) {
117
- return path.join(vaultPath, '.clawvault', OBSERVE_CURSOR_FILE);
118
- }
119
-
120
- function loadObserveCursors(vaultPath) {
121
- const cursorPath = getObserveCursorPath(vaultPath);
122
- if (!fs.existsSync(cursorPath)) {
123
- return {};
124
- }
125
-
126
- try {
127
- const parsed = JSON.parse(fs.readFileSync(cursorPath, 'utf-8'));
128
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
129
- return {};
130
- }
131
- return parsed;
132
- } catch {
133
- return {};
134
- }
135
- }
136
-
137
- function getScaledObservationThresholdBytes(fileSizeBytes) {
138
- if (!Number.isFinite(fileSizeBytes) || fileSizeBytes <= 0) {
139
- return SMALL_SESSION_THRESHOLD_BYTES;
140
- }
141
- if (fileSizeBytes < ONE_MIB) {
142
- return SMALL_SESSION_THRESHOLD_BYTES;
143
- }
144
- if (fileSizeBytes <= 5 * ONE_MIB) {
145
- return MEDIUM_SESSION_THRESHOLD_BYTES;
146
- }
147
- return LARGE_SESSION_THRESHOLD_BYTES;
148
- }
149
-
150
- function parseSessionIndex(agentId) {
151
- const sessionsDir = path.join(getOpenClawAgentsDir(), agentId, 'sessions');
152
- const sessionsJsonPath = path.join(sessionsDir, 'sessions.json');
153
- if (!fs.existsSync(sessionsJsonPath)) {
154
- return { sessionsDir, index: {} };
155
- }
156
-
157
- try {
158
- const parsed = JSON.parse(fs.readFileSync(sessionsJsonPath, 'utf-8'));
159
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
160
- return { sessionsDir, index: {} };
161
- }
162
- return { sessionsDir, index: parsed };
163
- } catch {
164
- return { sessionsDir, index: {} };
165
- }
166
- }
167
-
168
- function shouldObserveActiveSessions(vaultPath, agentId) {
169
- const cursors = loadObserveCursors(vaultPath);
170
- const { sessionsDir, index } = parseSessionIndex(agentId);
171
- const entries = Object.entries(index);
172
- if (entries.length === 0) {
173
- return false;
174
- }
175
-
176
- for (const [sessionKey, value] of entries) {
177
- if (!value || typeof value !== 'object') continue;
178
- const sessionId = typeof value.sessionId === 'string' ? value.sessionId.trim() : '';
179
- if (!/^[a-zA-Z0-9._-]{1,200}$/.test(sessionId)) continue;
180
-
181
- const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
182
- let stat;
183
- try {
184
- stat = fs.statSync(filePath);
185
- } catch {
186
- continue;
187
- }
188
- if (!stat.isFile()) continue;
189
-
190
- const fileSize = stat.size;
191
- const cursorEntry = cursors[sessionId];
192
- const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset)
193
- ? Math.max(0, Number(cursorEntry.lastObservedOffset))
194
- : 0;
195
- const startOffset = previousOffset <= fileSize ? previousOffset : 0;
196
- const newBytes = Math.max(0, fileSize - startOffset);
197
- const thresholdBytes = getScaledObservationThresholdBytes(fileSize);
198
-
199
- if (newBytes >= thresholdBytes) {
200
- console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`);
201
- return true;
202
- }
203
- }
204
-
205
- return false;
206
- }
207
-
208
- function extractTextFromMessage(message) {
209
- if (typeof message === 'string') return message;
210
- if (!message || typeof message !== 'object') return '';
211
-
212
- const content = message.content ?? message.text ?? message.message;
213
- if (typeof content === 'string') return content;
214
-
215
- if (Array.isArray(content)) {
216
- return content
217
- .map((part) => {
218
- if (typeof part === 'string') return part;
219
- if (!part || typeof part !== 'object') return '';
220
- if (typeof part.text === 'string') return part.text;
221
- if (typeof part.content === 'string') return part.content;
222
- return '';
223
- })
224
- .filter(Boolean)
225
- .join(' ');
226
- }
227
-
228
- return '';
229
- }
230
-
231
- function isUserMessage(message) {
232
- if (typeof message === 'string') return true;
233
- if (!message || typeof message !== 'object') return false;
234
- const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
235
- const type = typeof message.type === 'string' ? message.type.toLowerCase() : '';
236
- return role === 'user' || role === 'human' || type === 'user';
237
- }
238
-
239
- function extractInitialPrompt(event) {
240
- const fromContext = sanitizePromptForContext(event?.context?.initialPrompt);
241
- if (fromContext) return fromContext;
242
-
243
- const candidates = [
244
- event?.context?.messages,
245
- event?.context?.initialMessages,
246
- event?.context?.history,
247
- event?.messages
248
- ];
249
-
250
- for (const list of candidates) {
251
- if (!Array.isArray(list)) continue;
252
- for (const message of list) {
253
- if (!isUserMessage(message)) continue;
254
- const text = sanitizePromptForContext(extractTextFromMessage(message));
255
- if (text) return text;
256
- }
257
- }
258
-
259
- return '';
260
- }
261
-
262
- function truncateSnippet(snippet) {
263
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
264
- if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe;
265
- return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`;
266
- }
267
-
268
- function truncateRecapSnippet(snippet) {
269
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
270
- if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe;
271
- return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`;
272
- }
273
-
274
- function parseContextJson(output) {
275
- try {
276
- const parsed = JSON.parse(output);
277
- if (!parsed || !Array.isArray(parsed.context)) return [];
278
-
279
- return parsed.context
280
- .slice(0, MAX_CONTEXT_RESULTS)
281
- .map((entry) => ({
282
- title: sanitizeForDisplay(entry?.title || 'Untitled'),
283
- age: sanitizeForDisplay(entry?.age || 'unknown age'),
284
- snippet: truncateSnippet(entry?.snippet || '')
285
- }))
286
- .filter((entry) => entry.snippet);
287
- } catch {
288
- return [];
289
- }
290
- }
291
-
292
- function parseSessionRecapJson(output) {
293
- try {
294
- const parsed = JSON.parse(output);
295
- if (!parsed || !Array.isArray(parsed.messages)) return [];
296
-
297
- return parsed.messages
298
- .map((entry) => {
299
- if (!entry || typeof entry !== 'object') return null;
300
- const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : '';
301
- if (role !== 'user' && role !== 'assistant') return null;
302
- const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : '');
303
- if (!text) return null;
304
- return {
305
- role: role === 'user' ? 'User' : 'Assistant',
306
- text
307
- };
308
- })
309
- .filter(Boolean)
310
- .slice(-MAX_RECAP_RESULTS);
311
- } catch {
312
- return [];
313
- }
314
- }
315
-
316
- function formatSessionContextInjection(recapEntries, memoryEntries) {
317
- const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:'];
318
-
319
- if (recapEntries.length === 0) {
320
- lines.push('- No recent user/assistant turns found for this session.');
321
- } else {
322
- for (const entry of recapEntries) {
323
- lines.push(`- ${entry.role}: ${entry.text}`);
324
- }
325
- }
326
-
327
- lines.push('', 'Relevant memories:');
328
- if (memoryEntries.length === 0) {
329
- lines.push('- No relevant vault memories found for the current prompt.');
330
- } else {
331
- for (const entry of memoryEntries) {
332
- lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`);
333
- }
334
- }
335
-
336
- return lines.join('\n');
337
- }
338
-
339
- function injectSystemMessage(event, message) {
340
- if (!event.messages || !Array.isArray(event.messages)) return false;
341
-
342
- if (event.messages.length === 0) {
343
- event.messages.push(message);
344
- return true;
345
- }
346
-
347
- const first = event.messages[0];
348
- if (first && typeof first === 'object' && !Array.isArray(first)) {
349
- if ('role' in first || 'content' in first) {
350
- event.messages.push({ role: 'system', content: message });
351
- return true;
352
- }
353
- if ('type' in first || 'text' in first) {
354
- event.messages.push({ type: 'system', text: message });
355
- return true;
356
- }
357
- }
358
-
359
- event.messages.push(message);
360
- return true;
361
- }
362
-
363
- function normalizeEventToken(value) {
364
- if (typeof value !== 'string') return '';
365
- return value
366
- .trim()
367
- .toLowerCase()
368
- .replace(/\s+/g, '')
369
- .replace(EVENT_NAME_SEPARATOR_RE, ':');
370
- }
371
-
372
- function eventMatches(event, type, action) {
373
- const normalizedExpected = `${normalizeEventToken(type)}:${normalizeEventToken(action)}`;
374
- const normalizedType = normalizeEventToken(event?.type);
375
- const normalizedAction = normalizeEventToken(event?.action);
376
-
377
- if (normalizedType && normalizedAction) {
378
- if (`${normalizedType}:${normalizedAction}` === normalizedExpected) {
379
- return true;
380
- }
381
- }
382
-
383
- const aliases = [
384
- event?.event,
385
- event?.name,
386
- event?.hook,
387
- event?.trigger,
388
- event?.eventName
389
- ];
390
-
391
- for (const alias of aliases) {
392
- const normalizedAlias = normalizeEventToken(alias);
393
- if (!normalizedAlias) continue;
394
- if (normalizedAlias === normalizedExpected) {
395
- return true;
396
- }
397
- }
398
-
399
- return false;
400
- }
401
-
402
- function eventIncludesToken(event, token) {
403
- const normalizedToken = normalizeEventToken(token);
404
- if (!normalizedToken) return false;
405
-
406
- const values = [
407
- event?.type,
408
- event?.action,
409
- event?.event,
410
- event?.name,
411
- event?.hook,
412
- event?.trigger,
413
- event?.eventName
414
- ];
415
-
416
- return values
417
- .map((value) => normalizeEventToken(value))
418
- .filter(Boolean)
419
- .some((value) => value.includes(normalizedToken));
420
- }
421
-
422
- // Validate vault path - must be absolute and exist
423
- function validateVaultPath(vaultPath) {
424
- if (!vaultPath || typeof vaultPath !== 'string') return null;
425
-
426
- // Resolve to absolute path
427
- const resolved = path.resolve(vaultPath);
428
-
429
- // Must be absolute
430
- if (!path.isAbsolute(resolved)) return null;
431
-
432
- // Must exist and be a directory
433
- try {
434
- const stat = fs.statSync(resolved);
435
- if (!stat.isDirectory()) return null;
436
- } catch {
437
- return null;
438
- }
439
-
440
- // Must contain .clawvault.json
441
- const configPath = path.join(resolved, '.clawvault.json');
442
- if (!fs.existsSync(configPath)) return null;
443
-
444
- return resolved;
445
- }
446
-
447
- // Find vault by walking up directories
448
- function findVaultPath() {
449
- // Check env first
450
- if (process.env.CLAWVAULT_PATH) {
451
- return validateVaultPath(process.env.CLAWVAULT_PATH);
452
- }
453
-
454
- // Walk up from cwd
455
- let dir = process.cwd();
456
- const root = path.parse(dir).root;
457
-
458
- while (dir !== root) {
459
- const validated = validateVaultPath(dir);
460
- if (validated) return validated;
461
-
462
- // Also check memory/ subdirectory (OpenClaw convention)
463
- const memoryDir = path.join(dir, 'memory');
464
- const memoryValidated = validateVaultPath(memoryDir);
465
- if (memoryValidated) return memoryValidated;
466
-
467
- dir = path.dirname(dir);
468
- }
469
-
470
- return null;
471
- }
472
-
473
- // Run clawvault command safely (no shell)
474
- function runClawvault(args, options = {}) {
475
- const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000;
476
- try {
477
- // Use execFileSync to avoid shell injection
478
- // Arguments are passed as array, not interpolated into shell
479
- const output = execFileSync('clawvault', args, {
480
- encoding: 'utf-8',
481
- timeout: timeoutMs,
482
- stdio: ['pipe', 'pipe', 'pipe'],
483
- // Explicitly no shell
484
- shell: false
485
- });
486
- return { success: true, output: output.trim(), code: 0 };
487
- } catch (err) {
488
- return {
489
- success: false,
490
- output: err.stderr?.toString() || err.message || String(err),
491
- code: err.status || 1
492
- };
493
- }
494
- }
495
-
496
- // Parse recovery output safely
497
- function parseRecoveryOutput(output) {
498
- if (!output || typeof output !== 'string') {
499
- return { hadDeath: false, workingOn: null };
500
- }
501
-
502
- const hadDeath = output.includes('Context death detected') ||
503
- output.includes('died') ||
504
- output.includes('⚠️');
505
-
506
- let workingOn = null;
507
- if (hadDeath) {
508
- const lines = output.split('\n');
509
- const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
510
- if (workingOnLine) {
511
- const parts = workingOnLine.split(':');
512
- if (parts.length > 1) {
513
- workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim());
514
- }
515
- }
516
- }
517
-
518
- return { hadDeath, workingOn };
519
- }
520
-
521
- function resolveAgentIdForEvent(event) {
522
- const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event));
523
- if (fromSessionKey) return fromSessionKey;
524
-
525
- const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID);
526
- if (fromEnv) return fromEnv;
527
-
528
- return 'main';
529
- }
530
-
531
- function runObserverCron(vaultPath, agentId, options = {}) {
532
- const args = ['observe', '--cron', '--agent', agentId, '-v', vaultPath];
533
- if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) {
534
- args.push('--min-new', String(Math.floor(Number(options.minNewBytes))));
535
- }
536
-
537
- const result = runClawvault(args, { timeoutMs: 120000 });
538
- if (!result.success) {
539
- console.warn(`[clawvault] Observer cron failed (${options.reason || 'unknown reason'})`);
540
- return false;
541
- }
542
-
543
- if (result.output) {
544
- console.log(`[clawvault] Observer cron: ${result.output}`);
545
- } else {
546
- console.log('[clawvault] Observer cron: complete');
547
- }
548
- return true;
549
- }
550
-
551
- function extractEventTimestamp(event) {
552
- const candidates = [
553
- event?.timestamp,
554
- event?.scheduledAt,
555
- event?.time,
556
- event?.context?.timestamp,
557
- event?.context?.scheduledAt
558
- ];
559
- for (const candidate of candidates) {
560
- if (!candidate) continue;
561
- const parsed = new Date(candidate);
562
- if (!Number.isNaN(parsed.getTime())) {
563
- return parsed;
564
- }
565
- }
566
- return null;
567
- }
568
-
569
- function isSundayMidnightUtc(date) {
570
- return date.getUTCDay() === 0 && date.getUTCHours() === 0 && date.getUTCMinutes() === 0;
571
- }
572
-
573
- async function handleWeeklyReflect(event) {
574
- const vaultPath = findVaultPath();
575
- if (!vaultPath) {
576
- console.log('[clawvault] No vault found, skipping weekly reflection');
577
- return;
578
- }
579
-
580
- const timestamp = extractEventTimestamp(event) || new Date();
581
- if (!isSundayMidnightUtc(timestamp)) {
582
- console.log('[clawvault] Weekly reflect skipped (not Sunday midnight UTC)');
583
- return;
584
- }
585
-
586
- const result = runClawvault(['reflect', '-v', vaultPath], { timeoutMs: 120000 });
587
- if (!result.success) {
588
- console.warn('[clawvault] Weekly reflection failed');
589
- return;
590
- }
591
- console.log('[clawvault] Weekly reflection complete');
592
- }
593
-
594
- // Handle gateway startup - check for context death
595
- async function handleStartup(event) {
596
- const vaultPath = findVaultPath();
597
- if (!vaultPath) {
598
- console.log('[clawvault] No vault found, skipping recovery check');
599
- return;
600
- }
601
-
602
- console.log(`[clawvault] Checking for context death`);
603
-
604
- // Pass vault path as separate argument (not interpolated)
605
- const result = runClawvault(['recover', '--clear', '-v', vaultPath]);
606
-
607
- if (!result.success) {
608
- console.warn('[clawvault] Recovery check failed');
609
- return;
610
- }
611
-
612
- const { hadDeath, workingOn } = parseRecoveryOutput(result.output);
613
-
614
- if (hadDeath) {
615
- // Build safe alert message with sanitized content
616
- const alertParts = ['[ClawVault] Context death detected.'];
617
- if (workingOn) {
618
- alertParts.push(`Last working on: ${workingOn}`);
619
- }
620
- alertParts.push('Run `clawvault wake` for full recovery context.');
621
-
622
- const alertMsg = alertParts.join(' ');
623
-
624
- // Inject into event messages if available
625
- if (injectSystemMessage(event, alertMsg)) {
626
- console.warn('[clawvault] Context death detected, alert injected');
627
- }
628
- } else {
629
- console.log('[clawvault] Clean startup - no context death');
630
- }
631
- }
632
-
633
- // Handle /new command - auto-checkpoint before reset
634
- async function handleNew(event) {
635
- const vaultPath = findVaultPath();
636
- if (!vaultPath) {
637
- console.log('[clawvault] No vault found, skipping auto-checkpoint');
638
- return;
639
- }
640
-
641
- // Sanitize session info for checkpoint
642
- const sessionKey = typeof event.sessionKey === 'string'
643
- ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100)
644
- : 'unknown';
645
- const source = typeof event.context?.commandSource === 'string'
646
- ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50)
647
- : 'cli';
648
-
649
- console.log('[clawvault] Auto-checkpoint before /new');
650
-
651
- // Pass each argument separately (no shell interpolation)
652
- const result = runClawvault([
653
- 'checkpoint',
654
- '--working-on', `Session reset via /new from ${source}`,
655
- '--focus', `Pre-reset checkpoint, session: ${sessionKey}`,
656
- '-v', vaultPath
657
- ]);
658
-
659
- if (result.success) {
660
- console.log('[clawvault] Auto-checkpoint created');
661
- } else {
662
- console.warn('[clawvault] Auto-checkpoint failed');
663
- }
664
-
665
- const agentId = resolveAgentIdForEvent(event);
666
- runObserverCron(vaultPath, agentId, {
667
- minNewBytes: 1,
668
- reason: 'command:new flush'
669
- });
670
- }
671
-
672
- // Handle session start - inject dynamic context for first prompt
673
- async function handleSessionStart(event) {
674
- const vaultPath = findVaultPath();
675
- if (!vaultPath) {
676
- console.log('[clawvault] No vault found, skipping context injection');
677
- return;
678
- }
679
-
680
- const sessionKey = extractSessionKey(event);
681
- const prompt = extractInitialPrompt(event);
682
- let recapEntries = [];
683
- let memoryEntries = [];
684
-
685
- if (sessionKey) {
686
- console.log('[clawvault] Fetching session recap for context restoration');
687
- const recapArgs = ['session-recap', sessionKey, '--format', 'json'];
688
- const agentId = extractAgentIdFromSessionKey(sessionKey);
689
- if (agentId) {
690
- recapArgs.push('--agent', agentId);
691
- }
692
-
693
- const recapResult = runClawvault(recapArgs);
694
- if (!recapResult.success) {
695
- console.warn('[clawvault] Session recap lookup failed');
696
- } else {
697
- recapEntries = parseSessionRecapJson(recapResult.output);
698
- }
699
- } else {
700
- console.log('[clawvault] No session key found, skipping session recap');
701
- }
702
-
703
- if (prompt) {
704
- console.log('[clawvault] Fetching vault memories for session start prompt');
705
- const contextResult = runClawvault([
706
- 'context',
707
- prompt,
708
- '--format', 'json',
709
- '--profile', 'auto',
710
- '-v', vaultPath
711
- ]);
712
-
713
- if (!contextResult.success) {
714
- console.warn('[clawvault] Context lookup failed');
715
- } else {
716
- memoryEntries = parseContextJson(contextResult.output);
717
- }
718
- } else {
719
- console.log('[clawvault] No initial prompt, skipping vault memory lookup');
720
- }
721
-
722
- if (recapEntries.length === 0 && memoryEntries.length === 0) {
723
- console.log('[clawvault] No session context available to inject');
724
- return;
725
- }
726
-
727
- if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) {
728
- console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`);
729
- } else {
730
- console.log('[clawvault] No message array available, skipping injection');
731
- }
732
- }
733
-
734
- // Handle heartbeat events - cheap stat-based trigger for active observation
735
- async function handleHeartbeat(event) {
736
- const vaultPath = findVaultPath();
737
- if (!vaultPath) {
738
- console.log('[clawvault] No vault found, skipping heartbeat observation check');
739
- return;
740
- }
741
-
742
- const agentId = resolveAgentIdForEvent(event);
743
- if (!shouldObserveActiveSessions(vaultPath, agentId)) {
744
- console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold');
745
- return;
746
- }
747
-
748
- runObserverCron(vaultPath, agentId, { reason: 'heartbeat threshold crossed' });
749
- }
750
-
751
- // Handle context compaction - force flush any pending session deltas
752
- async function handleContextCompaction(event) {
753
- const vaultPath = findVaultPath();
754
- if (!vaultPath) {
755
- console.log('[clawvault] No vault found, skipping compaction observation');
756
- return;
757
- }
758
-
759
- const agentId = resolveAgentIdForEvent(event);
760
- runObserverCron(vaultPath, agentId, {
761
- minNewBytes: 1,
762
- reason: 'context compaction'
763
- });
764
- }
765
-
766
- // Main handler - route events
767
- const handler = async (event) => {
768
- try {
769
- if (eventMatches(event, 'gateway', 'startup')) {
770
- await handleStartup(event);
771
- return;
772
- }
773
-
774
- if (
775
- eventMatches(event, 'cron', 'weekly')
776
- || eventIncludesToken(event, 'cron:weekly')
777
- ) {
778
- await handleWeeklyReflect(event);
779
- return;
780
- }
781
-
782
- if (
783
- eventMatches(event, 'gateway', 'heartbeat')
784
- || eventMatches(event, 'session', 'heartbeat')
785
- || eventIncludesToken(event, 'heartbeat')
786
- ) {
787
- await handleHeartbeat(event);
788
- return;
789
- }
790
-
791
- if (
792
- eventMatches(event, 'compaction', 'memoryflush')
793
- || eventMatches(event, 'context', 'compaction')
794
- || eventMatches(event, 'context', 'compact')
795
- || eventIncludesToken(event, 'compaction')
796
- || eventIncludesToken(event, 'memoryflush')
797
- ) {
798
- await handleContextCompaction(event);
799
- return;
800
- }
801
-
802
- if (eventMatches(event, 'command', 'new')) {
803
- await handleNew(event);
804
- return;
805
- }
806
-
807
- if (eventMatches(event, 'session', 'start')) {
808
- await handleSessionStart(event);
809
- return;
810
- }
811
- } catch (err) {
812
- console.error('[clawvault] Hook error:', err.message || 'unknown error');
813
- }
814
- };
815
-
816
- export default handler;
1
+ /**
2
+ * ClawVault OpenClaw Hook
3
+ *
4
+ * Provides automatic context death resilience:
5
+ * - gateway:startup → detect context death, inject recovery info
6
+ * - gateway:heartbeat → cheap active-session threshold checks
7
+ * - command:new → auto-checkpoint before session reset
8
+ * - compaction:memoryFlush → force active-session flush before compaction
9
+ * - session:start → inject relevant context for first user prompt
10
+ *
11
+ * SECURITY: Uses execFileSync (no shell) to prevent command injection
12
+ */
13
+
14
+ import { execFileSync } from 'child_process';
15
+ import * as fs from 'fs';
16
+ import * as os from 'os';
17
+ import * as path from 'path';
18
+
19
+ const MAX_CONTEXT_RESULTS = 4;
20
+ const MAX_CONTEXT_PROMPT_LENGTH = 500;
21
+ const MAX_CONTEXT_SNIPPET_LENGTH = 220;
22
+ const MAX_RECAP_RESULTS = 6;
23
+ const MAX_RECAP_SNIPPET_LENGTH = 220;
24
+ const EVENT_NAME_SEPARATOR_RE = /[.:/]/g;
25
+ const OBSERVE_CURSOR_FILE = 'observe-cursors.json';
26
+ const ONE_KIB = 1024;
27
+ const ONE_MIB = ONE_KIB * ONE_KIB;
28
+ const SMALL_SESSION_THRESHOLD_BYTES = 50 * ONE_KIB;
29
+ const MEDIUM_SESSION_THRESHOLD_BYTES = 150 * ONE_KIB;
30
+ const LARGE_SESSION_THRESHOLD_BYTES = 300 * ONE_KIB;
31
+
32
+ // Sanitize string for safe display (prevent prompt injection via control chars)
33
+ function sanitizeForDisplay(str) {
34
+ if (typeof str !== 'string') return '';
35
+ // Remove control characters, limit length, escape markdown
36
+ return str
37
+ .replace(/[\x00-\x1f\x7f]/g, '') // Remove control chars
38
+ .replace(/[`*_~\[\]]/g, '\\$&') // Escape markdown
39
+ .slice(0, 200); // Limit length
40
+ }
41
+
42
+ // Sanitize prompt before passing to CLI command
43
+ function sanitizePromptForContext(str) {
44
+ if (typeof str !== 'string') return '';
45
+ return str
46
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim()
49
+ .slice(0, MAX_CONTEXT_PROMPT_LENGTH);
50
+ }
51
+
52
+ function sanitizeSessionKey(str) {
53
+ if (typeof str !== 'string') return '';
54
+ const trimmed = str.trim();
55
+ if (!/^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/.test(trimmed)) {
56
+ return '';
57
+ }
58
+ return trimmed.slice(0, 200);
59
+ }
60
+
61
+ function extractSessionKey(event) {
62
+ const candidates = [
63
+ event?.sessionKey,
64
+ event?.context?.sessionKey,
65
+ event?.session?.key,
66
+ event?.context?.session?.key,
67
+ event?.metadata?.sessionKey
68
+ ];
69
+
70
+ for (const candidate of candidates) {
71
+ const key = sanitizeSessionKey(candidate);
72
+ if (key) return key;
73
+ }
74
+
75
+ return '';
76
+ }
77
+
78
+ function extractAgentIdFromSessionKey(sessionKey) {
79
+ const match = /^agent:([^:]+):/.exec(sessionKey);
80
+ if (!match?.[1]) return '';
81
+ const agentId = match[1].trim();
82
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentId)) return '';
83
+ return agentId;
84
+ }
85
+
86
+ function sanitizeAgentId(agentId) {
87
+ if (typeof agentId !== 'string') return '';
88
+ const normalized = agentId.trim();
89
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalized)) return '';
90
+ return normalized;
91
+ }
92
+
93
+ function normalizeAbsoluteEnvPath(value) {
94
+ if (typeof value !== 'string') return null;
95
+ const trimmed = value.trim();
96
+ if (!trimmed) return null;
97
+ const resolved = path.resolve(trimmed);
98
+ if (!path.isAbsolute(resolved)) return null;
99
+ return resolved;
100
+ }
101
+
102
+ function getOpenClawAgentsDir() {
103
+ const stateDir = normalizeAbsoluteEnvPath(process.env.OPENCLAW_STATE_DIR);
104
+ if (stateDir) {
105
+ return path.join(stateDir, 'agents');
106
+ }
107
+
108
+ const openClawHome = normalizeAbsoluteEnvPath(process.env.OPENCLAW_HOME);
109
+ if (openClawHome) {
110
+ return path.join(openClawHome, 'agents');
111
+ }
112
+
113
+ return path.join(os.homedir(), '.openclaw', 'agents');
114
+ }
115
+
116
+ function getObserveCursorPath(vaultPath) {
117
+ return path.join(vaultPath, '.clawvault', OBSERVE_CURSOR_FILE);
118
+ }
119
+
120
+ function loadObserveCursors(vaultPath) {
121
+ const cursorPath = getObserveCursorPath(vaultPath);
122
+ if (!fs.existsSync(cursorPath)) {
123
+ return {};
124
+ }
125
+
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(cursorPath, 'utf-8'));
128
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
129
+ return {};
130
+ }
131
+ return parsed;
132
+ } catch {
133
+ return {};
134
+ }
135
+ }
136
+
137
+ function getScaledObservationThresholdBytes(fileSizeBytes) {
138
+ if (!Number.isFinite(fileSizeBytes) || fileSizeBytes <= 0) {
139
+ return SMALL_SESSION_THRESHOLD_BYTES;
140
+ }
141
+ if (fileSizeBytes < ONE_MIB) {
142
+ return SMALL_SESSION_THRESHOLD_BYTES;
143
+ }
144
+ if (fileSizeBytes <= 5 * ONE_MIB) {
145
+ return MEDIUM_SESSION_THRESHOLD_BYTES;
146
+ }
147
+ return LARGE_SESSION_THRESHOLD_BYTES;
148
+ }
149
+
150
+ function parseSessionIndex(agentId) {
151
+ const sessionsDir = path.join(getOpenClawAgentsDir(), agentId, 'sessions');
152
+ const sessionsJsonPath = path.join(sessionsDir, 'sessions.json');
153
+ if (!fs.existsSync(sessionsJsonPath)) {
154
+ return { sessionsDir, index: {} };
155
+ }
156
+
157
+ try {
158
+ const parsed = JSON.parse(fs.readFileSync(sessionsJsonPath, 'utf-8'));
159
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
160
+ return { sessionsDir, index: {} };
161
+ }
162
+ return { sessionsDir, index: parsed };
163
+ } catch {
164
+ return { sessionsDir, index: {} };
165
+ }
166
+ }
167
+
168
+ function shouldObserveActiveSessions(vaultPath, agentId) {
169
+ const cursors = loadObserveCursors(vaultPath);
170
+ const { sessionsDir, index } = parseSessionIndex(agentId);
171
+ const entries = Object.entries(index);
172
+ if (entries.length === 0) {
173
+ return false;
174
+ }
175
+
176
+ for (const [sessionKey, value] of entries) {
177
+ if (!value || typeof value !== 'object') continue;
178
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId.trim() : '';
179
+ if (!/^[a-zA-Z0-9._-]{1,200}$/.test(sessionId)) continue;
180
+
181
+ const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
182
+ let stat;
183
+ try {
184
+ stat = fs.statSync(filePath);
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (!stat.isFile()) continue;
189
+
190
+ const fileSize = stat.size;
191
+ const cursorEntry = cursors[sessionId];
192
+ const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset)
193
+ ? Math.max(0, Number(cursorEntry.lastObservedOffset))
194
+ : 0;
195
+ const startOffset = previousOffset <= fileSize ? previousOffset : 0;
196
+ const newBytes = Math.max(0, fileSize - startOffset);
197
+ const thresholdBytes = getScaledObservationThresholdBytes(fileSize);
198
+
199
+ if (newBytes >= thresholdBytes) {
200
+ console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`);
201
+ return true;
202
+ }
203
+ }
204
+
205
+ return false;
206
+ }
207
+
208
+ function extractTextFromMessage(message) {
209
+ if (typeof message === 'string') return message;
210
+ if (!message || typeof message !== 'object') return '';
211
+
212
+ const content = message.content ?? message.text ?? message.message;
213
+ if (typeof content === 'string') return content;
214
+
215
+ if (Array.isArray(content)) {
216
+ return content
217
+ .map((part) => {
218
+ if (typeof part === 'string') return part;
219
+ if (!part || typeof part !== 'object') return '';
220
+ if (typeof part.text === 'string') return part.text;
221
+ if (typeof part.content === 'string') return part.content;
222
+ return '';
223
+ })
224
+ .filter(Boolean)
225
+ .join(' ');
226
+ }
227
+
228
+ return '';
229
+ }
230
+
231
+ function isUserMessage(message) {
232
+ if (typeof message === 'string') return true;
233
+ if (!message || typeof message !== 'object') return false;
234
+ const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
235
+ const type = typeof message.type === 'string' ? message.type.toLowerCase() : '';
236
+ return role === 'user' || role === 'human' || type === 'user';
237
+ }
238
+
239
+ function extractInitialPrompt(event) {
240
+ const fromContext = sanitizePromptForContext(event?.context?.initialPrompt);
241
+ if (fromContext) return fromContext;
242
+
243
+ const candidates = [
244
+ event?.context?.messages,
245
+ event?.context?.initialMessages,
246
+ event?.context?.history,
247
+ event?.messages
248
+ ];
249
+
250
+ for (const list of candidates) {
251
+ if (!Array.isArray(list)) continue;
252
+ for (const message of list) {
253
+ if (!isUserMessage(message)) continue;
254
+ const text = sanitizePromptForContext(extractTextFromMessage(message));
255
+ if (text) return text;
256
+ }
257
+ }
258
+
259
+ return '';
260
+ }
261
+
262
+ function truncateSnippet(snippet) {
263
+ const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
264
+ if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe;
265
+ return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`;
266
+ }
267
+
268
+ function truncateRecapSnippet(snippet) {
269
+ const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
270
+ if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe;
271
+ return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`;
272
+ }
273
+
274
+ function parseContextJson(output) {
275
+ try {
276
+ const parsed = JSON.parse(output);
277
+ if (!parsed || !Array.isArray(parsed.context)) return [];
278
+
279
+ return parsed.context
280
+ .slice(0, MAX_CONTEXT_RESULTS)
281
+ .map((entry) => ({
282
+ title: sanitizeForDisplay(entry?.title || 'Untitled'),
283
+ age: sanitizeForDisplay(entry?.age || 'unknown age'),
284
+ snippet: truncateSnippet(entry?.snippet || '')
285
+ }))
286
+ .filter((entry) => entry.snippet);
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+
292
+ function parseSessionRecapJson(output) {
293
+ try {
294
+ const parsed = JSON.parse(output);
295
+ if (!parsed || !Array.isArray(parsed.messages)) return [];
296
+
297
+ return parsed.messages
298
+ .map((entry) => {
299
+ if (!entry || typeof entry !== 'object') return null;
300
+ const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : '';
301
+ if (role !== 'user' && role !== 'assistant') return null;
302
+ const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : '');
303
+ if (!text) return null;
304
+ return {
305
+ role: role === 'user' ? 'User' : 'Assistant',
306
+ text
307
+ };
308
+ })
309
+ .filter(Boolean)
310
+ .slice(-MAX_RECAP_RESULTS);
311
+ } catch {
312
+ return [];
313
+ }
314
+ }
315
+
316
+ function formatSessionContextInjection(recapEntries, memoryEntries) {
317
+ const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:'];
318
+
319
+ if (recapEntries.length === 0) {
320
+ lines.push('- No recent user/assistant turns found for this session.');
321
+ } else {
322
+ for (const entry of recapEntries) {
323
+ lines.push(`- ${entry.role}: ${entry.text}`);
324
+ }
325
+ }
326
+
327
+ lines.push('', 'Relevant memories:');
328
+ if (memoryEntries.length === 0) {
329
+ lines.push('- No relevant vault memories found for the current prompt.');
330
+ } else {
331
+ for (const entry of memoryEntries) {
332
+ lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`);
333
+ }
334
+ }
335
+
336
+ return lines.join('\n');
337
+ }
338
+
339
+ function injectSystemMessage(event, message) {
340
+ if (!event.messages || !Array.isArray(event.messages)) return false;
341
+
342
+ if (event.messages.length === 0) {
343
+ event.messages.push(message);
344
+ return true;
345
+ }
346
+
347
+ const first = event.messages[0];
348
+ if (first && typeof first === 'object' && !Array.isArray(first)) {
349
+ if ('role' in first || 'content' in first) {
350
+ event.messages.push({ role: 'system', content: message });
351
+ return true;
352
+ }
353
+ if ('type' in first || 'text' in first) {
354
+ event.messages.push({ type: 'system', text: message });
355
+ return true;
356
+ }
357
+ }
358
+
359
+ event.messages.push(message);
360
+ return true;
361
+ }
362
+
363
+ function normalizeEventToken(value) {
364
+ if (typeof value !== 'string') return '';
365
+ return value
366
+ .trim()
367
+ .toLowerCase()
368
+ .replace(/\s+/g, '')
369
+ .replace(EVENT_NAME_SEPARATOR_RE, ':');
370
+ }
371
+
372
+ function eventMatches(event, type, action) {
373
+ const normalizedExpected = `${normalizeEventToken(type)}:${normalizeEventToken(action)}`;
374
+ const normalizedType = normalizeEventToken(event?.type);
375
+ const normalizedAction = normalizeEventToken(event?.action);
376
+
377
+ if (normalizedType && normalizedAction) {
378
+ if (`${normalizedType}:${normalizedAction}` === normalizedExpected) {
379
+ return true;
380
+ }
381
+ }
382
+
383
+ const aliases = [
384
+ event?.event,
385
+ event?.name,
386
+ event?.hook,
387
+ event?.trigger,
388
+ event?.eventName
389
+ ];
390
+
391
+ for (const alias of aliases) {
392
+ const normalizedAlias = normalizeEventToken(alias);
393
+ if (!normalizedAlias) continue;
394
+ if (normalizedAlias === normalizedExpected) {
395
+ return true;
396
+ }
397
+ }
398
+
399
+ return false;
400
+ }
401
+
402
+ function eventIncludesToken(event, token) {
403
+ const normalizedToken = normalizeEventToken(token);
404
+ if (!normalizedToken) return false;
405
+
406
+ const values = [
407
+ event?.type,
408
+ event?.action,
409
+ event?.event,
410
+ event?.name,
411
+ event?.hook,
412
+ event?.trigger,
413
+ event?.eventName
414
+ ];
415
+
416
+ return values
417
+ .map((value) => normalizeEventToken(value))
418
+ .filter(Boolean)
419
+ .some((value) => value.includes(normalizedToken));
420
+ }
421
+
422
+ // Validate vault path - must be absolute and exist
423
+ function validateVaultPath(vaultPath) {
424
+ if (!vaultPath || typeof vaultPath !== 'string') return null;
425
+
426
+ // Resolve to absolute path
427
+ const resolved = path.resolve(vaultPath);
428
+
429
+ // Must be absolute
430
+ if (!path.isAbsolute(resolved)) return null;
431
+
432
+ // Must exist and be a directory
433
+ try {
434
+ const stat = fs.statSync(resolved);
435
+ if (!stat.isDirectory()) return null;
436
+ } catch {
437
+ return null;
438
+ }
439
+
440
+ // Must contain .clawvault.json
441
+ const configPath = path.join(resolved, '.clawvault.json');
442
+ if (!fs.existsSync(configPath)) return null;
443
+
444
+ return resolved;
445
+ }
446
+
447
+ // Find vault by walking up directories
448
+ function findVaultPath() {
449
+ // Check env first
450
+ if (process.env.CLAWVAULT_PATH) {
451
+ return validateVaultPath(process.env.CLAWVAULT_PATH);
452
+ }
453
+
454
+ // Walk up from cwd
455
+ let dir = process.cwd();
456
+ const root = path.parse(dir).root;
457
+
458
+ while (dir !== root) {
459
+ const validated = validateVaultPath(dir);
460
+ if (validated) return validated;
461
+
462
+ // Also check memory/ subdirectory (OpenClaw convention)
463
+ const memoryDir = path.join(dir, 'memory');
464
+ const memoryValidated = validateVaultPath(memoryDir);
465
+ if (memoryValidated) return memoryValidated;
466
+
467
+ dir = path.dirname(dir);
468
+ }
469
+
470
+ return null;
471
+ }
472
+
473
+ // Run clawvault command safely (no shell)
474
+ function runClawvault(args, options = {}) {
475
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000;
476
+ try {
477
+ // Use execFileSync to avoid shell injection
478
+ // Arguments are passed as array, not interpolated into shell
479
+ const output = execFileSync('clawvault', args, {
480
+ encoding: 'utf-8',
481
+ timeout: timeoutMs,
482
+ stdio: ['pipe', 'pipe', 'pipe'],
483
+ // Explicitly no shell
484
+ shell: false
485
+ });
486
+ return { success: true, output: output.trim(), code: 0 };
487
+ } catch (err) {
488
+ return {
489
+ success: false,
490
+ output: err.stderr?.toString() || err.message || String(err),
491
+ code: err.status || 1
492
+ };
493
+ }
494
+ }
495
+
496
+ // Parse recovery output safely
497
+ function parseRecoveryOutput(output) {
498
+ if (!output || typeof output !== 'string') {
499
+ return { hadDeath: false, workingOn: null };
500
+ }
501
+
502
+ const hadDeath = output.includes('Context death detected') ||
503
+ output.includes('died') ||
504
+ output.includes('⚠️');
505
+
506
+ let workingOn = null;
507
+ if (hadDeath) {
508
+ const lines = output.split('\n');
509
+ const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
510
+ if (workingOnLine) {
511
+ const parts = workingOnLine.split(':');
512
+ if (parts.length > 1) {
513
+ workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim());
514
+ }
515
+ }
516
+ }
517
+
518
+ return { hadDeath, workingOn };
519
+ }
520
+
521
+ function resolveAgentIdForEvent(event) {
522
+ const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event));
523
+ if (fromSessionKey) return fromSessionKey;
524
+
525
+ const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID);
526
+ if (fromEnv) return fromEnv;
527
+
528
+ return 'main';
529
+ }
530
+
531
+ function runObserverCron(vaultPath, agentId, options = {}) {
532
+ const args = ['observe', '--cron', '--agent', agentId, '-v', vaultPath];
533
+ if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) {
534
+ args.push('--min-new', String(Math.floor(Number(options.minNewBytes))));
535
+ }
536
+
537
+ const result = runClawvault(args, { timeoutMs: 120000 });
538
+ if (!result.success) {
539
+ console.warn(`[clawvault] Observer cron failed (${options.reason || 'unknown reason'})`);
540
+ return false;
541
+ }
542
+
543
+ if (result.output) {
544
+ console.log(`[clawvault] Observer cron: ${result.output}`);
545
+ } else {
546
+ console.log('[clawvault] Observer cron: complete');
547
+ }
548
+ return true;
549
+ }
550
+
551
+ function extractEventTimestamp(event) {
552
+ const candidates = [
553
+ event?.timestamp,
554
+ event?.scheduledAt,
555
+ event?.time,
556
+ event?.context?.timestamp,
557
+ event?.context?.scheduledAt
558
+ ];
559
+ for (const candidate of candidates) {
560
+ if (!candidate) continue;
561
+ const parsed = new Date(candidate);
562
+ if (!Number.isNaN(parsed.getTime())) {
563
+ return parsed;
564
+ }
565
+ }
566
+ return null;
567
+ }
568
+
569
+ function isSundayMidnightUtc(date) {
570
+ return date.getUTCDay() === 0 && date.getUTCHours() === 0 && date.getUTCMinutes() === 0;
571
+ }
572
+
573
+ async function handleWeeklyReflect(event) {
574
+ const vaultPath = findVaultPath();
575
+ if (!vaultPath) {
576
+ console.log('[clawvault] No vault found, skipping weekly reflection');
577
+ return;
578
+ }
579
+
580
+ const timestamp = extractEventTimestamp(event) || new Date();
581
+ if (!isSundayMidnightUtc(timestamp)) {
582
+ console.log('[clawvault] Weekly reflect skipped (not Sunday midnight UTC)');
583
+ return;
584
+ }
585
+
586
+ const result = runClawvault(['reflect', '-v', vaultPath], { timeoutMs: 120000 });
587
+ if (!result.success) {
588
+ console.warn('[clawvault] Weekly reflection failed');
589
+ return;
590
+ }
591
+ console.log('[clawvault] Weekly reflection complete');
592
+ }
593
+
594
+ // Handle gateway startup - check for context death
595
+ async function handleStartup(event) {
596
+ const vaultPath = findVaultPath();
597
+ if (!vaultPath) {
598
+ console.log('[clawvault] No vault found, skipping recovery check');
599
+ return;
600
+ }
601
+
602
+ console.log(`[clawvault] Checking for context death`);
603
+
604
+ // Pass vault path as separate argument (not interpolated)
605
+ const result = runClawvault(['recover', '--clear', '-v', vaultPath]);
606
+
607
+ if (!result.success) {
608
+ console.warn('[clawvault] Recovery check failed');
609
+ return;
610
+ }
611
+
612
+ const { hadDeath, workingOn } = parseRecoveryOutput(result.output);
613
+
614
+ if (hadDeath) {
615
+ // Build safe alert message with sanitized content
616
+ const alertParts = ['[ClawVault] Context death detected.'];
617
+ if (workingOn) {
618
+ alertParts.push(`Last working on: ${workingOn}`);
619
+ }
620
+ alertParts.push('Run `clawvault wake` for full recovery context.');
621
+
622
+ const alertMsg = alertParts.join(' ');
623
+
624
+ // Inject into event messages if available
625
+ if (injectSystemMessage(event, alertMsg)) {
626
+ console.warn('[clawvault] Context death detected, alert injected');
627
+ }
628
+ } else {
629
+ console.log('[clawvault] Clean startup - no context death');
630
+ }
631
+ }
632
+
633
+ // Handle /new command - auto-checkpoint before reset
634
+ async function handleNew(event) {
635
+ const vaultPath = findVaultPath();
636
+ if (!vaultPath) {
637
+ console.log('[clawvault] No vault found, skipping auto-checkpoint');
638
+ return;
639
+ }
640
+
641
+ // Sanitize session info for checkpoint
642
+ const sessionKey = typeof event.sessionKey === 'string'
643
+ ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100)
644
+ : 'unknown';
645
+ const source = typeof event.context?.commandSource === 'string'
646
+ ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50)
647
+ : 'cli';
648
+
649
+ console.log('[clawvault] Auto-checkpoint before /new');
650
+
651
+ // Pass each argument separately (no shell interpolation)
652
+ const result = runClawvault([
653
+ 'checkpoint',
654
+ '--working-on', `Session reset via /new from ${source}`,
655
+ '--focus', `Pre-reset checkpoint, session: ${sessionKey}`,
656
+ '-v', vaultPath
657
+ ]);
658
+
659
+ if (result.success) {
660
+ console.log('[clawvault] Auto-checkpoint created');
661
+ } else {
662
+ console.warn('[clawvault] Auto-checkpoint failed');
663
+ }
664
+
665
+ const agentId = resolveAgentIdForEvent(event);
666
+ runObserverCron(vaultPath, agentId, {
667
+ minNewBytes: 1,
668
+ reason: 'command:new flush'
669
+ });
670
+ }
671
+
672
+ // Handle session start - inject dynamic context for first prompt
673
+ async function handleSessionStart(event) {
674
+ const vaultPath = findVaultPath();
675
+ if (!vaultPath) {
676
+ console.log('[clawvault] No vault found, skipping context injection');
677
+ return;
678
+ }
679
+
680
+ const sessionKey = extractSessionKey(event);
681
+ const prompt = extractInitialPrompt(event);
682
+ let recapEntries = [];
683
+ let memoryEntries = [];
684
+
685
+ if (sessionKey) {
686
+ console.log('[clawvault] Fetching session recap for context restoration');
687
+ const recapArgs = ['session-recap', sessionKey, '--format', 'json'];
688
+ const agentId = extractAgentIdFromSessionKey(sessionKey);
689
+ if (agentId) {
690
+ recapArgs.push('--agent', agentId);
691
+ }
692
+
693
+ const recapResult = runClawvault(recapArgs);
694
+ if (!recapResult.success) {
695
+ console.warn('[clawvault] Session recap lookup failed');
696
+ } else {
697
+ recapEntries = parseSessionRecapJson(recapResult.output);
698
+ }
699
+ } else {
700
+ console.log('[clawvault] No session key found, skipping session recap');
701
+ }
702
+
703
+ if (prompt) {
704
+ console.log('[clawvault] Fetching vault memories for session start prompt');
705
+ const contextResult = runClawvault([
706
+ 'context',
707
+ prompt,
708
+ '--format', 'json',
709
+ '--profile', 'auto',
710
+ '-v', vaultPath
711
+ ]);
712
+
713
+ if (!contextResult.success) {
714
+ console.warn('[clawvault] Context lookup failed');
715
+ } else {
716
+ memoryEntries = parseContextJson(contextResult.output);
717
+ }
718
+ } else {
719
+ console.log('[clawvault] No initial prompt, skipping vault memory lookup');
720
+ }
721
+
722
+ if (recapEntries.length === 0 && memoryEntries.length === 0) {
723
+ console.log('[clawvault] No session context available to inject');
724
+ return;
725
+ }
726
+
727
+ if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) {
728
+ console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`);
729
+ } else {
730
+ console.log('[clawvault] No message array available, skipping injection');
731
+ }
732
+ }
733
+
734
+ // Handle heartbeat events - cheap stat-based trigger for active observation
735
+ async function handleHeartbeat(event) {
736
+ const vaultPath = findVaultPath();
737
+ if (!vaultPath) {
738
+ console.log('[clawvault] No vault found, skipping heartbeat observation check');
739
+ return;
740
+ }
741
+
742
+ const agentId = resolveAgentIdForEvent(event);
743
+ if (!shouldObserveActiveSessions(vaultPath, agentId)) {
744
+ console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold');
745
+ return;
746
+ }
747
+
748
+ runObserverCron(vaultPath, agentId, { reason: 'heartbeat threshold crossed' });
749
+ }
750
+
751
+ // Handle context compaction - force flush any pending session deltas
752
+ async function handleContextCompaction(event) {
753
+ const vaultPath = findVaultPath();
754
+ if (!vaultPath) {
755
+ console.log('[clawvault] No vault found, skipping compaction observation');
756
+ return;
757
+ }
758
+
759
+ const agentId = resolveAgentIdForEvent(event);
760
+ runObserverCron(vaultPath, agentId, {
761
+ minNewBytes: 1,
762
+ reason: 'context compaction'
763
+ });
764
+ }
765
+
766
+ // Main handler - route events
767
+ const handler = async (event) => {
768
+ try {
769
+ if (eventMatches(event, 'gateway', 'startup')) {
770
+ await handleStartup(event);
771
+ return;
772
+ }
773
+
774
+ if (
775
+ eventMatches(event, 'cron', 'weekly')
776
+ || eventIncludesToken(event, 'cron:weekly')
777
+ ) {
778
+ await handleWeeklyReflect(event);
779
+ return;
780
+ }
781
+
782
+ if (
783
+ eventMatches(event, 'gateway', 'heartbeat')
784
+ || eventMatches(event, 'session', 'heartbeat')
785
+ || eventIncludesToken(event, 'heartbeat')
786
+ ) {
787
+ await handleHeartbeat(event);
788
+ return;
789
+ }
790
+
791
+ if (
792
+ eventMatches(event, 'compaction', 'memoryflush')
793
+ || eventMatches(event, 'context', 'compaction')
794
+ || eventMatches(event, 'context', 'compact')
795
+ || eventIncludesToken(event, 'compaction')
796
+ || eventIncludesToken(event, 'memoryflush')
797
+ ) {
798
+ await handleContextCompaction(event);
799
+ return;
800
+ }
801
+
802
+ if (eventMatches(event, 'command', 'new')) {
803
+ await handleNew(event);
804
+ return;
805
+ }
806
+
807
+ if (eventMatches(event, 'session', 'start')) {
808
+ await handleSessionStart(event);
809
+ return;
810
+ }
811
+ } catch (err) {
812
+ console.error('[clawvault] Hook error:', err.message || 'unknown error');
813
+ }
814
+ };
815
+
816
+ export default handler;