clawvault 3.0.0 → 3.1.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 (60) hide show
  1. package/README.md +156 -105
  2. package/bin/clawvault.js +0 -2
  3. package/bin/register-core-commands.js +20 -2
  4. package/dist/{chunk-3D6BCTP6.js → chunk-33UGEQRT.js} +70 -145
  5. package/dist/{chunk-ZVVFWOLW.js → chunk-3WRJEKN4.js} +1 -1
  6. package/dist/{chunk-DEFFDRVP.js → chunk-3ZIH425O.js} +3 -70
  7. package/dist/{chunk-K234IDRJ.js → chunk-D2H45LON.js} +1 -0
  8. package/dist/{chunk-YKTA5JOJ.js → chunk-H62BP7RI.js} +3 -3
  9. package/dist/{chunk-WGRQ6HDV.js → chunk-LI4O6NVK.js} +1 -1
  10. package/dist/{chunk-7R7O6STJ.js → chunk-OCGVIN3L.js} +1 -1
  11. package/dist/{chunk-GAJV4IGR.js → chunk-YCUNCH2I.js} +3 -7
  12. package/dist/cli/index.cjs +10 -1459
  13. package/dist/cli/index.js +5 -8
  14. package/dist/commands/compat.cjs +70 -145
  15. package/dist/commands/compat.js +1 -1
  16. package/dist/commands/context.cjs +1 -0
  17. package/dist/commands/context.js +3 -3
  18. package/dist/commands/doctor.cjs +68 -144
  19. package/dist/commands/doctor.js +4 -4
  20. package/dist/commands/embed.js +2 -2
  21. package/dist/commands/setup.cjs +2 -69
  22. package/dist/commands/setup.d.cts +0 -1
  23. package/dist/commands/setup.d.ts +0 -1
  24. package/dist/commands/setup.js +2 -2
  25. package/dist/commands/sleep.cjs +1 -0
  26. package/dist/commands/sleep.js +2 -2
  27. package/dist/commands/status.cjs +1 -0
  28. package/dist/commands/status.js +2 -2
  29. package/dist/commands/wake.cjs +1 -0
  30. package/dist/commands/wake.js +2 -2
  31. package/dist/index.cjs +447 -2600
  32. package/dist/index.d.cts +0 -4
  33. package/dist/index.d.ts +0 -4
  34. package/dist/index.js +8 -69
  35. package/dist/plugin/index.cjs +3 -3
  36. package/dist/plugin/index.js +10 -10
  37. package/package.json +11 -17
  38. package/bin/register-tailscale-commands.js +0 -106
  39. package/dist/chunk-IVRIKYFE.js +0 -520
  40. package/dist/chunk-THRJVD4L.js +0 -373
  41. package/dist/chunk-TIGW564L.js +0 -628
  42. package/dist/commands/tailscale.cjs +0 -1532
  43. package/dist/commands/tailscale.d.cts +0 -52
  44. package/dist/commands/tailscale.d.ts +0 -52
  45. package/dist/commands/tailscale.js +0 -26
  46. package/dist/lib/canvas-layout.cjs +0 -136
  47. package/dist/lib/canvas-layout.d.cts +0 -31
  48. package/dist/lib/canvas-layout.d.ts +0 -31
  49. package/dist/lib/canvas-layout.js +0 -92
  50. package/dist/lib/tailscale.cjs +0 -1183
  51. package/dist/lib/tailscale.d.cts +0 -225
  52. package/dist/lib/tailscale.d.ts +0 -225
  53. package/dist/lib/tailscale.js +0 -50
  54. package/dist/lib/webdav.cjs +0 -568
  55. package/dist/lib/webdav.d.cts +0 -109
  56. package/dist/lib/webdav.d.ts +0 -109
  57. package/dist/lib/webdav.js +0 -35
  58. package/hooks/clawvault/HOOK.md +0 -83
  59. package/hooks/clawvault/handler.js +0 -879
  60. package/hooks/clawvault/handler.test.js +0 -354
@@ -1,879 +0,0 @@
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
- let 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
- // After /new or /reset, the main .jsonl is emptied — fall back to reset file
191
- if (stat.size < 100) {
192
- const resetPrefix = `${sessionId}.jsonl.reset.`;
193
- try {
194
- const resetFiles = fs.readdirSync(sessionsDir)
195
- .filter(f => f.startsWith(resetPrefix))
196
- .sort()
197
- .reverse();
198
- if (resetFiles.length > 0) {
199
- const resetPath = path.join(sessionsDir, resetFiles[0]);
200
- const resetStat = fs.statSync(resetPath);
201
- if (resetStat.isFile() && resetStat.size > stat.size) {
202
- filePath = resetPath;
203
- stat = resetStat;
204
- }
205
- }
206
- } catch {
207
- // Ignore — use original file
208
- }
209
- }
210
-
211
- const fileSize = stat.size;
212
- const cursorEntry = cursors[sessionId];
213
- const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset)
214
- ? Math.max(0, Number(cursorEntry.lastObservedOffset))
215
- : 0;
216
- const startOffset = previousOffset <= fileSize ? previousOffset : 0;
217
- const newBytes = Math.max(0, fileSize - startOffset);
218
- const thresholdBytes = getScaledObservationThresholdBytes(fileSize);
219
-
220
- if (newBytes >= thresholdBytes) {
221
- console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`);
222
- return true;
223
- }
224
- }
225
-
226
- return false;
227
- }
228
-
229
- function extractTextFromMessage(message) {
230
- if (typeof message === 'string') return message;
231
- if (!message || typeof message !== 'object') return '';
232
-
233
- const content = message.content ?? message.text ?? message.message;
234
- if (typeof content === 'string') return content;
235
-
236
- if (Array.isArray(content)) {
237
- return content
238
- .map((part) => {
239
- if (typeof part === 'string') return part;
240
- if (!part || typeof part !== 'object') return '';
241
- if (typeof part.text === 'string') return part.text;
242
- if (typeof part.content === 'string') return part.content;
243
- return '';
244
- })
245
- .filter(Boolean)
246
- .join(' ');
247
- }
248
-
249
- return '';
250
- }
251
-
252
- function isUserMessage(message) {
253
- if (typeof message === 'string') return true;
254
- if (!message || typeof message !== 'object') return false;
255
- const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
256
- const type = typeof message.type === 'string' ? message.type.toLowerCase() : '';
257
- return role === 'user' || role === 'human' || type === 'user';
258
- }
259
-
260
- function extractInitialPrompt(event) {
261
- const fromContext = sanitizePromptForContext(event?.context?.initialPrompt);
262
- if (fromContext) return fromContext;
263
-
264
- const candidates = [
265
- event?.context?.messages,
266
- event?.context?.initialMessages,
267
- event?.context?.history,
268
- event?.messages
269
- ];
270
-
271
- for (const list of candidates) {
272
- if (!Array.isArray(list)) continue;
273
- for (const message of list) {
274
- if (!isUserMessage(message)) continue;
275
- const text = sanitizePromptForContext(extractTextFromMessage(message));
276
- if (text) return text;
277
- }
278
- }
279
-
280
- return '';
281
- }
282
-
283
- function truncateSnippet(snippet) {
284
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
285
- if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe;
286
- return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`;
287
- }
288
-
289
- function truncateRecapSnippet(snippet) {
290
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
291
- if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe;
292
- return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`;
293
- }
294
-
295
- function parseContextJson(output) {
296
- try {
297
- const parsed = JSON.parse(output);
298
- if (!parsed || !Array.isArray(parsed.context)) return [];
299
-
300
- return parsed.context
301
- .slice(0, MAX_CONTEXT_RESULTS)
302
- .map((entry) => ({
303
- title: sanitizeForDisplay(entry?.title || 'Untitled'),
304
- age: sanitizeForDisplay(entry?.age || 'unknown age'),
305
- snippet: truncateSnippet(entry?.snippet || '')
306
- }))
307
- .filter((entry) => entry.snippet);
308
- } catch {
309
- return [];
310
- }
311
- }
312
-
313
- function parseSessionRecapJson(output) {
314
- try {
315
- const parsed = JSON.parse(output);
316
- if (!parsed || !Array.isArray(parsed.messages)) return [];
317
-
318
- return parsed.messages
319
- .map((entry) => {
320
- if (!entry || typeof entry !== 'object') return null;
321
- const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : '';
322
- if (role !== 'user' && role !== 'assistant') return null;
323
- const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : '');
324
- if (!text) return null;
325
- return {
326
- role: role === 'user' ? 'User' : 'Assistant',
327
- text
328
- };
329
- })
330
- .filter(Boolean)
331
- .slice(-MAX_RECAP_RESULTS);
332
- } catch {
333
- return [];
334
- }
335
- }
336
-
337
- function formatSessionContextInjection(recapEntries, memoryEntries) {
338
- const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:'];
339
-
340
- if (recapEntries.length === 0) {
341
- lines.push('- No recent user/assistant turns found for this session.');
342
- } else {
343
- for (const entry of recapEntries) {
344
- lines.push(`- ${entry.role}: ${entry.text}`);
345
- }
346
- }
347
-
348
- lines.push('', 'Relevant memories:');
349
- if (memoryEntries.length === 0) {
350
- lines.push('- No relevant vault memories found for the current prompt.');
351
- } else {
352
- for (const entry of memoryEntries) {
353
- lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`);
354
- }
355
- }
356
-
357
- return lines.join('\n');
358
- }
359
-
360
- function injectSystemMessage(event, message) {
361
- if (!event.messages || !Array.isArray(event.messages)) return false;
362
-
363
- if (event.messages.length === 0) {
364
- event.messages.push(message);
365
- return true;
366
- }
367
-
368
- const first = event.messages[0];
369
- if (first && typeof first === 'object' && !Array.isArray(first)) {
370
- if ('role' in first || 'content' in first) {
371
- event.messages.push({ role: 'system', content: message });
372
- return true;
373
- }
374
- if ('type' in first || 'text' in first) {
375
- event.messages.push({ type: 'system', text: message });
376
- return true;
377
- }
378
- }
379
-
380
- event.messages.push(message);
381
- return true;
382
- }
383
-
384
- function normalizeEventToken(value) {
385
- if (typeof value !== 'string') return '';
386
- return value
387
- .trim()
388
- .toLowerCase()
389
- .replace(/\s+/g, '')
390
- .replace(EVENT_NAME_SEPARATOR_RE, ':')
391
- .replace(/^:+|:+$/g, '');
392
- }
393
-
394
- function collapseEventToken(value) {
395
- return normalizeEventToken(value).replace(/:/g, '');
396
- }
397
-
398
- function eventMatches(event, type, action) {
399
- const normalizedExpected = [normalizeEventToken(type), normalizeEventToken(action)]
400
- .filter(Boolean)
401
- .join(':');
402
- const collapsedExpected = collapseEventToken(normalizedExpected);
403
- const normalizedType = normalizeEventToken(event?.type);
404
- const normalizedAction = normalizeEventToken(event?.action);
405
-
406
- if (normalizedType && normalizedAction) {
407
- const normalizedEventPair = `${normalizedType}:${normalizedAction}`;
408
- if (
409
- normalizedEventPair === normalizedExpected
410
- || collapseEventToken(normalizedEventPair) === collapsedExpected
411
- ) {
412
- return true;
413
- }
414
- }
415
-
416
- const aliases = [
417
- event?.event,
418
- event?.name,
419
- event?.hook,
420
- event?.trigger,
421
- event?.eventName
422
- ];
423
-
424
- for (const alias of aliases) {
425
- const normalizedAlias = normalizeEventToken(alias);
426
- if (!normalizedAlias) continue;
427
- if (
428
- normalizedAlias === normalizedExpected
429
- || collapseEventToken(normalizedAlias) === collapsedExpected
430
- ) {
431
- return true;
432
- }
433
- }
434
-
435
- return false;
436
- }
437
-
438
- function eventIncludesToken(event, token) {
439
- const normalizedToken = normalizeEventToken(token);
440
- if (!normalizedToken) return false;
441
- const collapsedToken = collapseEventToken(normalizedToken);
442
-
443
- const values = [
444
- event?.type,
445
- event?.action,
446
- event?.event,
447
- event?.name,
448
- event?.hook,
449
- event?.trigger,
450
- event?.eventName
451
- ];
452
-
453
- return values
454
- .map((value) => normalizeEventToken(value))
455
- .filter(Boolean)
456
- .some((value) => {
457
- if (value.includes(normalizedToken)) {
458
- return true;
459
- }
460
- return collapseEventToken(value).includes(collapsedToken);
461
- });
462
- }
463
-
464
- // Validate vault path - must be absolute and exist
465
- function validateVaultPath(vaultPath) {
466
- if (!vaultPath || typeof vaultPath !== 'string') return null;
467
-
468
- // Resolve to absolute path
469
- const resolved = path.resolve(vaultPath);
470
-
471
- // Must be absolute
472
- if (!path.isAbsolute(resolved)) return null;
473
-
474
- // Must exist and be a directory
475
- try {
476
- const stat = fs.statSync(resolved);
477
- if (!stat.isDirectory()) return null;
478
- } catch {
479
- return null;
480
- }
481
-
482
- // Must contain .clawvault.json
483
- const configPath = path.join(resolved, '.clawvault.json');
484
- if (!fs.existsSync(configPath)) return null;
485
-
486
- return resolved;
487
- }
488
-
489
- // Find vault by walking up directories
490
- function findVaultPath() {
491
- // Check env first
492
- if (process.env.CLAWVAULT_PATH) {
493
- return validateVaultPath(process.env.CLAWVAULT_PATH);
494
- }
495
- const configuredCandidates = [
496
- process.env.OPENCLAW_MEMORY_PATH,
497
- process.env.OPENCLAW_VAULT_PATH
498
- ];
499
- for (const candidate of configuredCandidates) {
500
- const validated = validateVaultPath(candidate);
501
- if (validated) return validated;
502
- }
503
-
504
- // Walk up from cwd
505
- let dir = process.cwd();
506
- const root = path.parse(dir).root;
507
-
508
- while (dir !== root) {
509
- const validated = validateVaultPath(dir);
510
- if (validated) return validated;
511
-
512
- // Also check memory/ subdirectory (OpenClaw convention)
513
- const memoryDir = path.join(dir, 'memory');
514
- const memoryValidated = validateVaultPath(memoryDir);
515
- if (memoryValidated) return memoryValidated;
516
-
517
- dir = path.dirname(dir);
518
- }
519
-
520
- // Canonical local-first defaults for OpenClaw users.
521
- const homeDir = normalizeAbsoluteEnvPath(process.env.HOME) || os.homedir();
522
- const homeCandidates = [
523
- path.join(homeDir, 'memory'),
524
- path.join(homeDir, 'Memory'),
525
- path.join(homeDir, 'vault'),
526
- path.join(homeDir, 'Vault')
527
- ];
528
- for (const candidate of homeCandidates) {
529
- const validated = validateVaultPath(candidate);
530
- if (validated) return validated;
531
- }
532
-
533
- return null;
534
- }
535
-
536
- // Run clawvault command safely (no shell)
537
- function runClawvault(args, options = {}) {
538
- const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000;
539
- try {
540
- // Use execFileSync to avoid shell injection
541
- // Arguments are passed as array, not interpolated into shell
542
- const output = execFileSync('clawvault', args, {
543
- encoding: 'utf-8',
544
- timeout: timeoutMs,
545
- stdio: ['pipe', 'pipe', 'pipe'],
546
- // Explicitly no shell
547
- shell: false
548
- });
549
- return { success: true, output: output.trim(), code: 0 };
550
- } catch (err) {
551
- return {
552
- success: false,
553
- output: err.stderr?.toString() || err.message || String(err),
554
- code: err.status || 1
555
- };
556
- }
557
- }
558
-
559
- // Parse recovery output safely
560
- function parseRecoveryOutput(output) {
561
- if (!output || typeof output !== 'string') {
562
- return { hadDeath: false, workingOn: null };
563
- }
564
-
565
- const hadDeath = output.includes('Context death detected') ||
566
- output.includes('died') ||
567
- output.includes('⚠️');
568
-
569
- let workingOn = null;
570
- if (hadDeath) {
571
- const lines = output.split('\n');
572
- const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
573
- if (workingOnLine) {
574
- const parts = workingOnLine.split(':');
575
- if (parts.length > 1) {
576
- workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim());
577
- }
578
- }
579
- }
580
-
581
- return { hadDeath, workingOn };
582
- }
583
-
584
- function resolveAgentIdForEvent(event) {
585
- const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event));
586
- if (fromSessionKey) return fromSessionKey;
587
-
588
- const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID);
589
- if (fromEnv) return fromEnv;
590
-
591
- return 'main';
592
- }
593
-
594
- function runObserverCron(vaultPath, agentId, options = {}) {
595
- const args = ['observe', '--cron', '--agent', agentId, '-v', vaultPath];
596
- if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) {
597
- args.push('--min-new', String(Math.floor(Number(options.minNewBytes))));
598
- }
599
-
600
- const result = runClawvault(args, { timeoutMs: 120000 });
601
- if (!result.success) {
602
- console.warn(`[clawvault] Observer cron failed (${options.reason || 'unknown reason'})`);
603
- return false;
604
- }
605
-
606
- if (result.output) {
607
- console.log(`[clawvault] Observer cron: ${result.output}`);
608
- } else {
609
- console.log('[clawvault] Observer cron: complete');
610
- }
611
- return true;
612
- }
613
-
614
- function extractEventTimestamp(event) {
615
- const candidates = [
616
- event?.timestamp,
617
- event?.scheduledAt,
618
- event?.time,
619
- event?.context?.timestamp,
620
- event?.context?.scheduledAt
621
- ];
622
- for (const candidate of candidates) {
623
- if (!candidate) continue;
624
- const parsed = new Date(candidate);
625
- if (!Number.isNaN(parsed.getTime())) {
626
- return parsed;
627
- }
628
- }
629
- return null;
630
- }
631
-
632
- function isSundayMidnightUtc(date) {
633
- return date.getUTCDay() === 0 && date.getUTCHours() === 0 && date.getUTCMinutes() === 0;
634
- }
635
-
636
- async function handleWeeklyReflect(event) {
637
- const vaultPath = findVaultPath();
638
- if (!vaultPath) {
639
- console.log('[clawvault] No vault found, skipping weekly reflection');
640
- return;
641
- }
642
-
643
- const timestamp = extractEventTimestamp(event) || new Date();
644
- if (!isSundayMidnightUtc(timestamp)) {
645
- console.log('[clawvault] Weekly reflect skipped (not Sunday midnight UTC)');
646
- return;
647
- }
648
-
649
- const result = runClawvault(['reflect', '-v', vaultPath], { timeoutMs: 120000 });
650
- if (!result.success) {
651
- console.warn('[clawvault] Weekly reflection failed');
652
- return;
653
- }
654
- console.log('[clawvault] Weekly reflection complete');
655
- }
656
-
657
- // Handle gateway startup - check for context death
658
- async function handleStartup(event) {
659
- const vaultPath = findVaultPath();
660
- if (!vaultPath) {
661
- console.log('[clawvault] No vault found, skipping recovery check');
662
- return;
663
- }
664
-
665
- console.log(`[clawvault] Checking for context death`);
666
-
667
- // Pass vault path as separate argument (not interpolated)
668
- const result = runClawvault(['recover', '--clear', '-v', vaultPath]);
669
-
670
- if (!result.success) {
671
- console.warn('[clawvault] Recovery check failed');
672
- return;
673
- }
674
-
675
- const { hadDeath, workingOn } = parseRecoveryOutput(result.output);
676
-
677
- if (hadDeath) {
678
- // Build safe alert message with sanitized content
679
- const alertParts = ['[ClawVault] Context death detected.'];
680
- if (workingOn) {
681
- alertParts.push(`Last working on: ${workingOn}`);
682
- }
683
- alertParts.push('Run `clawvault wake` for full recovery context.');
684
-
685
- const alertMsg = alertParts.join(' ');
686
-
687
- // Inject into event messages if available
688
- if (injectSystemMessage(event, alertMsg)) {
689
- console.warn('[clawvault] Context death detected, alert injected');
690
- }
691
- } else {
692
- console.log('[clawvault] Clean startup - no context death');
693
- }
694
- }
695
-
696
- // Handle /new command - auto-checkpoint before reset
697
- async function handleNew(event) {
698
- const vaultPath = findVaultPath();
699
- if (!vaultPath) {
700
- console.log('[clawvault] No vault found, skipping auto-checkpoint');
701
- return;
702
- }
703
-
704
- // Sanitize session info for checkpoint
705
- const sessionKey = typeof event.sessionKey === 'string'
706
- ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100)
707
- : 'unknown';
708
- const source = typeof event.context?.commandSource === 'string'
709
- ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50)
710
- : 'cli';
711
-
712
- console.log('[clawvault] Auto-checkpoint before /new');
713
-
714
- // Pass each argument separately (no shell interpolation)
715
- const result = runClawvault([
716
- 'checkpoint',
717
- '--working-on', `Session reset via /new from ${source}`,
718
- '--focus', `Pre-reset checkpoint, session: ${sessionKey}`,
719
- '-v', vaultPath
720
- ]);
721
-
722
- if (result.success) {
723
- console.log('[clawvault] Auto-checkpoint created');
724
- } else {
725
- console.warn('[clawvault] Auto-checkpoint failed');
726
- }
727
-
728
- const agentId = resolveAgentIdForEvent(event);
729
- runObserverCron(vaultPath, agentId, {
730
- minNewBytes: 1,
731
- reason: 'command:new flush'
732
- });
733
- }
734
-
735
- // Handle session start - inject dynamic context for first prompt
736
- async function handleSessionStart(event) {
737
- const vaultPath = findVaultPath();
738
- if (!vaultPath) {
739
- console.log('[clawvault] No vault found, skipping context injection');
740
- return;
741
- }
742
-
743
- const sessionKey = extractSessionKey(event);
744
- const prompt = extractInitialPrompt(event);
745
- let recapEntries = [];
746
- let memoryEntries = [];
747
-
748
- if (sessionKey) {
749
- console.log('[clawvault] Fetching session recap for context restoration');
750
- const recapArgs = ['session-recap', sessionKey, '--format', 'json'];
751
- const agentId = extractAgentIdFromSessionKey(sessionKey);
752
- if (agentId) {
753
- recapArgs.push('--agent', agentId);
754
- }
755
-
756
- const recapResult = runClawvault(recapArgs);
757
- if (!recapResult.success) {
758
- console.warn('[clawvault] Session recap lookup failed');
759
- } else {
760
- recapEntries = parseSessionRecapJson(recapResult.output);
761
- }
762
- } else {
763
- console.log('[clawvault] No session key found, skipping session recap');
764
- }
765
-
766
- if (prompt) {
767
- console.log('[clawvault] Fetching vault memories for session start prompt');
768
- const contextResult = runClawvault([
769
- 'context',
770
- prompt,
771
- '--format', 'json',
772
- '--profile', 'auto',
773
- '-v', vaultPath
774
- ]);
775
-
776
- if (!contextResult.success) {
777
- console.warn('[clawvault] Context lookup failed');
778
- } else {
779
- memoryEntries = parseContextJson(contextResult.output);
780
- }
781
- } else {
782
- console.log('[clawvault] No initial prompt, skipping vault memory lookup');
783
- }
784
-
785
- if (recapEntries.length === 0 && memoryEntries.length === 0) {
786
- console.log('[clawvault] No session context available to inject');
787
- return;
788
- }
789
-
790
- if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) {
791
- console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`);
792
- } else {
793
- console.log('[clawvault] No message array available, skipping injection');
794
- }
795
- }
796
-
797
- // Handle heartbeat events - cheap stat-based trigger for active observation
798
- async function handleHeartbeat(event) {
799
- const vaultPath = findVaultPath();
800
- if (!vaultPath) {
801
- console.log('[clawvault] No vault found, skipping heartbeat observation check');
802
- return;
803
- }
804
-
805
- const agentId = resolveAgentIdForEvent(event);
806
- if (!shouldObserveActiveSessions(vaultPath, agentId)) {
807
- console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold');
808
- return;
809
- }
810
-
811
- runObserverCron(vaultPath, agentId, { reason: 'heartbeat threshold crossed' });
812
- }
813
-
814
- // Handle context compaction - force flush any pending session deltas
815
- async function handleContextCompaction(event) {
816
- const vaultPath = findVaultPath();
817
- if (!vaultPath) {
818
- console.log('[clawvault] No vault found, skipping compaction observation');
819
- return;
820
- }
821
-
822
- const agentId = resolveAgentIdForEvent(event);
823
- runObserverCron(vaultPath, agentId, {
824
- minNewBytes: 1,
825
- reason: 'context compaction'
826
- });
827
- }
828
-
829
- // Main handler - route events
830
- const handler = async (event) => {
831
- try {
832
- if (eventMatches(event, 'gateway', 'startup')) {
833
- await handleStartup(event);
834
- return;
835
- }
836
-
837
- if (
838
- eventMatches(event, 'cron', 'weekly')
839
- || eventIncludesToken(event, 'cron:weekly')
840
- ) {
841
- await handleWeeklyReflect(event);
842
- return;
843
- }
844
-
845
- if (
846
- eventMatches(event, 'gateway', 'heartbeat')
847
- || eventMatches(event, 'session', 'heartbeat')
848
- || eventIncludesToken(event, 'heartbeat')
849
- ) {
850
- await handleHeartbeat(event);
851
- return;
852
- }
853
-
854
- if (
855
- eventMatches(event, 'compaction', 'memoryflush')
856
- || eventMatches(event, 'context', 'compaction')
857
- || eventMatches(event, 'context', 'compact')
858
- || eventIncludesToken(event, 'compaction')
859
- || eventIncludesToken(event, 'memoryflush')
860
- ) {
861
- await handleContextCompaction(event);
862
- return;
863
- }
864
-
865
- if (eventMatches(event, 'command', 'new')) {
866
- await handleNew(event);
867
- return;
868
- }
869
-
870
- if (eventMatches(event, 'session', 'start')) {
871
- await handleSessionStart(event);
872
- return;
873
- }
874
- } catch (err) {
875
- console.error('[clawvault] Hook error:', err.message || 'unknown error');
876
- }
877
- };
878
-
879
- export default handler;