clawvault 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -26
  4. package/SKILL.md +369 -0
  5. package/dist/{chunk-X3SPPUFG.js → chunk-JI7VUQV7.js} +118 -132
  6. package/dist/{chunk-QYQAGBTM.js → chunk-QUFQBAHP.js} +148 -125
  7. package/dist/cli/index.js +1 -1
  8. package/dist/commands/compat.js +1 -1
  9. package/dist/commands/observe.js +1 -1
  10. package/dist/commands/status.js +4 -4
  11. package/dist/index.js +11 -8
  12. package/dist/openclaw-plugin.js +6 -1
  13. package/docs/clawhub-security-release-playbook.md +75 -0
  14. package/docs/getting-started/installation.md +99 -0
  15. package/docs/openclaw-plugin-usage.md +152 -0
  16. package/openclaw.plugin.json +1 -1
  17. package/package.json +26 -8
  18. package/bin/command-registration.test.js +0 -179
  19. package/bin/command-runtime.test.js +0 -154
  20. package/bin/help-contract.test.js +0 -55
  21. package/bin/register-config-route-commands.test.js +0 -121
  22. package/bin/register-core-commands.test.js +0 -80
  23. package/bin/register-kanban-commands.test.js +0 -83
  24. package/bin/register-project-commands.test.js +0 -206
  25. package/bin/register-query-commands.test.js +0 -80
  26. package/bin/register-resilience-commands.test.js +0 -81
  27. package/bin/register-task-commands.test.js +0 -69
  28. package/bin/register-template-commands.test.js +0 -87
  29. package/bin/test-helpers/cli-command-fixtures.js +0 -120
  30. package/dashboard/lib/graph-diff.test.js +0 -75
  31. package/dashboard/lib/vault-parser.test.js +0 -254
  32. package/hooks/clawvault/HOOK.md +0 -130
  33. package/hooks/clawvault/handler.js +0 -1696
  34. package/hooks/clawvault/handler.test.js +0 -576
  35. package/hooks/clawvault/integrity.js +0 -112
  36. package/hooks/clawvault/integrity.test.js +0 -32
  37. package/hooks/clawvault/openclaw.plugin.json +0 -190
@@ -1,1696 +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 { createHash, randomUUID } from 'crypto';
16
- import * as fs from 'fs';
17
- import * as os from 'os';
18
- import * as path from 'path';
19
- import {
20
- resolveExecutablePath,
21
- sanitizeExecArgs,
22
- verifyExecutableIntegrity
23
- } from './integrity.js';
24
-
25
- const MAX_CONTEXT_RESULTS = 4;
26
- const MAX_CONTEXT_PROMPT_LENGTH = 500;
27
- const MAX_CONTEXT_SNIPPET_LENGTH = 220;
28
- const MAX_RECAP_RESULTS = 6;
29
- const MAX_RECAP_SNIPPET_LENGTH = 220;
30
- const EVENT_NAME_SEPARATOR_RE = /[.:/]/g;
31
- const OBSERVE_CURSOR_FILE = 'observe-cursors.json';
32
- const ONE_KIB = 1024;
33
- const ONE_MIB = ONE_KIB * ONE_KIB;
34
- const SMALL_SESSION_THRESHOLD_BYTES = 50 * ONE_KIB;
35
- const MEDIUM_SESSION_THRESHOLD_BYTES = 150 * ONE_KIB;
36
- const LARGE_SESSION_THRESHOLD_BYTES = 300 * ONE_KIB;
37
- const FACTS_FILE = 'facts.jsonl';
38
- const ENTITY_GRAPH_FILE = 'entity-graph.json';
39
- const ENTITY_GRAPH_VERSION = 1;
40
- const MAX_FACT_TEXT_LENGTH = 600;
41
- const FACT_SENTENCE_SPLIT_RE = /[.!?]+\s+|\r?\n+/;
42
- const EXCLUSIVE_FACT_RELATIONS = new Set(['lives_in', 'works_at', 'age']);
43
- const ENTITY_TARGET_RELATIONS = new Set(['works_at', 'lives_in', 'partner_name', 'dog_name', 'parent_name']);
44
- const CLAWVAULT_EXECUTABLE = 'clawvault';
45
-
46
- // Sanitize string for safe display (prevent prompt injection via control chars)
47
- function sanitizeForDisplay(str) {
48
- if (typeof str !== 'string') return '';
49
- // Remove control characters, limit length, escape markdown
50
- return str
51
- .replace(/[\x00-\x1f\x7f]/g, '') // Remove control chars
52
- .replace(/[`*_~\[\]]/g, '\\$&') // Escape markdown
53
- .slice(0, 200); // Limit length
54
- }
55
-
56
- // Sanitize prompt before passing to CLI command
57
- function sanitizePromptForContext(str) {
58
- if (typeof str !== 'string') return '';
59
- return str
60
- .replace(/[\x00-\x1f\x7f]/g, ' ')
61
- .replace(/\s+/g, ' ')
62
- .trim()
63
- .slice(0, MAX_CONTEXT_PROMPT_LENGTH);
64
- }
65
-
66
- function sanitizeSessionKey(str) {
67
- if (typeof str !== 'string') return '';
68
- const trimmed = str.trim();
69
- if (!/^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/.test(trimmed)) {
70
- return '';
71
- }
72
- return trimmed.slice(0, 200);
73
- }
74
-
75
- function extractSessionKey(event) {
76
- const candidates = [
77
- event?.sessionKey,
78
- event?.context?.sessionKey,
79
- event?.session?.key,
80
- event?.context?.session?.key,
81
- event?.metadata?.sessionKey
82
- ];
83
-
84
- for (const candidate of candidates) {
85
- const key = sanitizeSessionKey(candidate);
86
- if (key) return key;
87
- }
88
-
89
- return '';
90
- }
91
-
92
- function extractAgentIdFromSessionKey(sessionKey) {
93
- const match = /^agent:([^:]+):/.exec(sessionKey);
94
- if (!match?.[1]) return '';
95
- const agentId = match[1].trim();
96
- if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentId)) return '';
97
- return agentId;
98
- }
99
-
100
- function sanitizeAgentId(agentId) {
101
- if (typeof agentId !== 'string') return '';
102
- const normalized = agentId.trim();
103
- if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalized)) return '';
104
- return normalized;
105
- }
106
-
107
- function normalizeAbsoluteEnvPath(value) {
108
- if (typeof value !== 'string') return null;
109
- const trimmed = value.trim();
110
- if (!trimmed) return null;
111
- const resolved = path.resolve(trimmed);
112
- if (!path.isAbsolute(resolved)) return null;
113
- return resolved;
114
- }
115
-
116
- function getOpenClawAgentsDir(pluginConfig) {
117
- if (allowsEnvAccess(pluginConfig)) {
118
- const stateDir = normalizeAbsoluteEnvPath(process.env.OPENCLAW_STATE_DIR);
119
- if (stateDir) {
120
- return path.join(stateDir, 'agents');
121
- }
122
-
123
- const openClawHome = normalizeAbsoluteEnvPath(process.env.OPENCLAW_HOME);
124
- if (openClawHome) {
125
- return path.join(openClawHome, 'agents');
126
- }
127
- }
128
-
129
- return path.join(os.homedir(), '.openclaw', 'agents');
130
- }
131
-
132
- function getObserveCursorPath(vaultPath) {
133
- return path.join(vaultPath, '.clawvault', OBSERVE_CURSOR_FILE);
134
- }
135
-
136
- function loadObserveCursors(vaultPath) {
137
- const cursorPath = getObserveCursorPath(vaultPath);
138
- if (!fs.existsSync(cursorPath)) {
139
- return {};
140
- }
141
-
142
- try {
143
- const parsed = JSON.parse(fs.readFileSync(cursorPath, 'utf-8'));
144
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
145
- return {};
146
- }
147
- return parsed;
148
- } catch {
149
- return {};
150
- }
151
- }
152
-
153
- function getScaledObservationThresholdBytes(fileSizeBytes) {
154
- if (!Number.isFinite(fileSizeBytes) || fileSizeBytes <= 0) {
155
- return SMALL_SESSION_THRESHOLD_BYTES;
156
- }
157
- if (fileSizeBytes < ONE_MIB) {
158
- return SMALL_SESSION_THRESHOLD_BYTES;
159
- }
160
- if (fileSizeBytes <= 5 * ONE_MIB) {
161
- return MEDIUM_SESSION_THRESHOLD_BYTES;
162
- }
163
- return LARGE_SESSION_THRESHOLD_BYTES;
164
- }
165
-
166
- function parseSessionIndex(agentId, pluginConfig) {
167
- const sessionsDir = path.join(getOpenClawAgentsDir(pluginConfig), agentId, 'sessions');
168
- const sessionsJsonPath = path.join(sessionsDir, 'sessions.json');
169
- if (!fs.existsSync(sessionsJsonPath)) {
170
- return { sessionsDir, index: {} };
171
- }
172
-
173
- try {
174
- const parsed = JSON.parse(fs.readFileSync(sessionsJsonPath, 'utf-8'));
175
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
176
- return { sessionsDir, index: {} };
177
- }
178
- return { sessionsDir, index: parsed };
179
- } catch {
180
- return { sessionsDir, index: {} };
181
- }
182
- }
183
-
184
- function shouldObserveActiveSessions(vaultPath, agentId, pluginConfig) {
185
- const cursors = loadObserveCursors(vaultPath);
186
- const { sessionsDir, index } = parseSessionIndex(agentId, pluginConfig);
187
- const entries = Object.entries(index);
188
- if (entries.length === 0) {
189
- return false;
190
- }
191
-
192
- for (const [sessionKey, value] of entries) {
193
- if (!value || typeof value !== 'object') continue;
194
- const sessionId = typeof value.sessionId === 'string' ? value.sessionId.trim() : '';
195
- if (!/^[a-zA-Z0-9._-]{1,200}$/.test(sessionId)) continue;
196
-
197
- const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
198
- let stat;
199
- try {
200
- stat = fs.statSync(filePath);
201
- } catch {
202
- continue;
203
- }
204
- if (!stat.isFile()) continue;
205
-
206
- const fileSize = stat.size;
207
- const cursorEntry = cursors[sessionId];
208
- const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset)
209
- ? Math.max(0, Number(cursorEntry.lastObservedOffset))
210
- : 0;
211
- const startOffset = previousOffset <= fileSize ? previousOffset : 0;
212
- const newBytes = Math.max(0, fileSize - startOffset);
213
- const thresholdBytes = getScaledObservationThresholdBytes(fileSize);
214
-
215
- if (newBytes >= thresholdBytes) {
216
- console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`);
217
- return true;
218
- }
219
- }
220
-
221
- return false;
222
- }
223
-
224
- function extractTextFromMessage(message) {
225
- if (typeof message === 'string') return message;
226
- if (!message || typeof message !== 'object') return '';
227
-
228
- const content = message.content ?? message.text ?? message.message;
229
- if (typeof content === 'string') return content;
230
-
231
- if (Array.isArray(content)) {
232
- return content
233
- .map((part) => {
234
- if (typeof part === 'string') return part;
235
- if (!part || typeof part !== 'object') return '';
236
- if (typeof part.text === 'string') return part.text;
237
- if (typeof part.content === 'string') return part.content;
238
- return '';
239
- })
240
- .filter(Boolean)
241
- .join(' ');
242
- }
243
-
244
- return '';
245
- }
246
-
247
- function isUserMessage(message) {
248
- if (typeof message === 'string') return true;
249
- if (!message || typeof message !== 'object') return false;
250
- const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
251
- const type = typeof message.type === 'string' ? message.type.toLowerCase() : '';
252
- return role === 'user' || role === 'human' || type === 'user';
253
- }
254
-
255
- function extractInitialPrompt(event) {
256
- const fromContext = sanitizePromptForContext(event?.context?.initialPrompt);
257
- if (fromContext) return fromContext;
258
-
259
- const candidates = [
260
- event?.context?.messages,
261
- event?.context?.initialMessages,
262
- event?.context?.history,
263
- event?.messages
264
- ];
265
-
266
- for (const list of candidates) {
267
- if (!Array.isArray(list)) continue;
268
- for (const message of list) {
269
- if (!isUserMessage(message)) continue;
270
- const text = sanitizePromptForContext(extractTextFromMessage(message));
271
- if (text) return text;
272
- }
273
- }
274
-
275
- return '';
276
- }
277
-
278
- function truncateSnippet(snippet) {
279
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
280
- if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe;
281
- return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`;
282
- }
283
-
284
- function truncateRecapSnippet(snippet) {
285
- const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
286
- if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe;
287
- return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`;
288
- }
289
-
290
- function parseContextJson(output) {
291
- try {
292
- const parsed = JSON.parse(output);
293
- if (!parsed || !Array.isArray(parsed.context)) return [];
294
-
295
- return parsed.context
296
- .slice(0, MAX_CONTEXT_RESULTS)
297
- .map((entry) => ({
298
- title: sanitizeForDisplay(entry?.title || 'Untitled'),
299
- age: sanitizeForDisplay(entry?.age || 'unknown age'),
300
- snippet: truncateSnippet(entry?.snippet || '')
301
- }))
302
- .filter((entry) => entry.snippet);
303
- } catch {
304
- return [];
305
- }
306
- }
307
-
308
- function parseSessionRecapJson(output) {
309
- try {
310
- const parsed = JSON.parse(output);
311
- if (!parsed || !Array.isArray(parsed.messages)) return [];
312
-
313
- return parsed.messages
314
- .map((entry) => {
315
- if (!entry || typeof entry !== 'object') return null;
316
- const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : '';
317
- if (role !== 'user' && role !== 'assistant') return null;
318
- const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : '');
319
- if (!text) return null;
320
- return {
321
- role: role === 'user' ? 'User' : 'Assistant',
322
- text
323
- };
324
- })
325
- .filter(Boolean)
326
- .slice(-MAX_RECAP_RESULTS);
327
- } catch {
328
- return [];
329
- }
330
- }
331
-
332
- function formatSessionContextInjection(recapEntries, memoryEntries) {
333
- const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:'];
334
-
335
- if (recapEntries.length === 0) {
336
- lines.push('- No recent user/assistant turns found for this session.');
337
- } else {
338
- for (const entry of recapEntries) {
339
- lines.push(`- ${entry.role}: ${entry.text}`);
340
- }
341
- }
342
-
343
- lines.push('', 'Relevant memories:');
344
- if (memoryEntries.length === 0) {
345
- lines.push('- No relevant vault memories found for the current prompt.');
346
- } else {
347
- for (const entry of memoryEntries) {
348
- lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`);
349
- }
350
- }
351
-
352
- return lines.join('\n');
353
- }
354
-
355
- function injectSystemMessage(event, message) {
356
- if (!event.messages || !Array.isArray(event.messages)) return false;
357
-
358
- if (event.messages.length === 0) {
359
- event.messages.push(message);
360
- return true;
361
- }
362
-
363
- const first = event.messages[0];
364
- if (first && typeof first === 'object' && !Array.isArray(first)) {
365
- if ('role' in first || 'content' in first) {
366
- event.messages.push({ role: 'system', content: message });
367
- return true;
368
- }
369
- if ('type' in first || 'text' in first) {
370
- event.messages.push({ type: 'system', text: message });
371
- return true;
372
- }
373
- }
374
-
375
- event.messages.push(message);
376
- return true;
377
- }
378
-
379
- function normalizeEventToken(value) {
380
- if (typeof value !== 'string') return '';
381
- return value
382
- .trim()
383
- .toLowerCase()
384
- .replace(/\s+/g, '')
385
- .replace(EVENT_NAME_SEPARATOR_RE, ':');
386
- }
387
-
388
- function eventMatches(event, type, action) {
389
- const normalizedExpected = `${normalizeEventToken(type)}:${normalizeEventToken(action)}`;
390
- const normalizedType = normalizeEventToken(event?.type);
391
- const normalizedAction = normalizeEventToken(event?.action);
392
-
393
- if (normalizedType && normalizedAction) {
394
- if (`${normalizedType}:${normalizedAction}` === normalizedExpected) {
395
- return true;
396
- }
397
- }
398
-
399
- const aliases = [
400
- event?.event,
401
- event?.name,
402
- event?.hook,
403
- event?.trigger,
404
- event?.eventName
405
- ];
406
-
407
- for (const alias of aliases) {
408
- const normalizedAlias = normalizeEventToken(alias);
409
- if (!normalizedAlias) continue;
410
- if (normalizedAlias === normalizedExpected) {
411
- return true;
412
- }
413
- }
414
-
415
- return false;
416
- }
417
-
418
- function eventIncludesToken(event, token) {
419
- const normalizedToken = normalizeEventToken(token);
420
- if (!normalizedToken) return false;
421
-
422
- const values = [
423
- event?.type,
424
- event?.action,
425
- event?.event,
426
- event?.name,
427
- event?.hook,
428
- event?.trigger,
429
- event?.eventName
430
- ];
431
-
432
- return values
433
- .map((value) => normalizeEventToken(value))
434
- .filter(Boolean)
435
- .some((value) => value.includes(normalizedToken));
436
- }
437
-
438
- // Validate vault path - must be absolute and exist
439
- function validateVaultPath(vaultPath) {
440
- if (!vaultPath || typeof vaultPath !== 'string') return null;
441
-
442
- // Resolve to absolute path
443
- const resolved = path.resolve(vaultPath);
444
-
445
- // Must be absolute
446
- if (!path.isAbsolute(resolved)) return null;
447
-
448
- // Must exist and be a directory
449
- try {
450
- const stat = fs.statSync(resolved);
451
- if (!stat.isDirectory()) return null;
452
- } catch {
453
- return null;
454
- }
455
-
456
- // Must contain .clawvault.json
457
- const configPath = path.join(resolved, '.clawvault.json');
458
- if (!fs.existsSync(configPath)) return null;
459
-
460
- return resolved;
461
- }
462
-
463
- // Extract plugin config from event context (set via openclaw config)
464
- function extractPluginConfig(event) {
465
- const candidates = [
466
- event?.pluginConfig,
467
- event?.context?.pluginConfig,
468
- event?.config?.plugins?.entries?.clawvault?.config,
469
- event?.context?.config?.plugins?.entries?.clawvault?.config,
470
- event?.config?.plugins?.clawvault?.config,
471
- event?.context?.config?.plugins?.clawvault?.config
472
- ];
473
-
474
- for (const candidate of candidates) {
475
- if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
476
- return candidate;
477
- }
478
- }
479
-
480
- return {};
481
- }
482
-
483
- function isOptInEnabled(pluginConfig, ...keys) {
484
- for (const key of keys) {
485
- if (pluginConfig?.[key] === true) return true;
486
- }
487
- return false;
488
- }
489
-
490
- function allowsEnvAccess(pluginConfig) {
491
- return isOptInEnabled(pluginConfig, 'allowEnvAccess');
492
- }
493
-
494
- function getConfiguredExecutablePath(pluginConfig) {
495
- const value = pluginConfig?.clawvaultBinaryPath;
496
- if (typeof value !== 'string') return null;
497
- const trimmed = value.trim();
498
- return trimmed || null;
499
- }
500
-
501
- function getConfiguredExecutableSha256(pluginConfig) {
502
- const value = pluginConfig?.clawvaultBinarySha256;
503
- if (typeof value !== 'string') return null;
504
- const trimmed = value.trim().toLowerCase();
505
- return trimmed || null;
506
- }
507
-
508
- // Resolve vault path for a specific agent from agentVaults config
509
- function resolveAgentVaultPath(pluginConfig, agentId) {
510
- if (!agentId || typeof agentId !== 'string') return null;
511
-
512
- const agentVaults = pluginConfig?.agentVaults;
513
- if (!agentVaults || typeof agentVaults !== 'object' || Array.isArray(agentVaults)) {
514
- return null;
515
- }
516
-
517
- const agentPath = agentVaults[agentId];
518
- if (!agentPath || typeof agentPath !== 'string') return null;
519
-
520
- return validateVaultPath(agentPath);
521
- }
522
-
523
- // Find vault by walking up directories
524
- // Supports per-agent vault paths via agentVaults config
525
- function findVaultPath(event, pluginConfig, options = {}) {
526
- // Determine agent ID for per-agent vault resolution
527
- const agentId = options.agentId || resolveAgentIdForEvent(event, pluginConfig);
528
-
529
- // Check agentVaults first (per-agent vault paths)
530
- if (agentId) {
531
- const agentVaultPath = resolveAgentVaultPath(pluginConfig, agentId);
532
- if (agentVaultPath) {
533
- console.log(`[clawvault] Using per-agent vault for ${agentId}: ${agentVaultPath}`);
534
- return agentVaultPath;
535
- }
536
- }
537
-
538
- // Check plugin config vaultPath (fallback for all agents)
539
- if (pluginConfig.vaultPath) {
540
- const validated = validateVaultPath(pluginConfig.vaultPath);
541
- if (validated) return validated;
542
- }
543
-
544
- if (allowsEnvAccess(pluginConfig)) {
545
- // Check OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH env (injected by OpenClaw from plugin config)
546
- if (process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH) {
547
- const validated = validateVaultPath(process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH);
548
- if (validated) return validated;
549
- }
550
-
551
- // Check CLAWVAULT_PATH env
552
- if (process.env.CLAWVAULT_PATH) {
553
- return validateVaultPath(process.env.CLAWVAULT_PATH);
554
- }
555
- }
556
-
557
- // Walk up from cwd
558
- let dir = process.cwd();
559
- const root = path.parse(dir).root;
560
-
561
- while (dir !== root) {
562
- const validated = validateVaultPath(dir);
563
- if (validated) return validated;
564
-
565
- // Also check memory/ subdirectory (OpenClaw convention)
566
- const memoryDir = path.join(dir, 'memory');
567
- const memoryValidated = validateVaultPath(memoryDir);
568
- if (memoryValidated) return memoryValidated;
569
-
570
- dir = path.dirname(dir);
571
- }
572
-
573
- return null;
574
- }
575
-
576
- // Run clawvault command safely (no shell)
577
- function runClawvault(args, pluginConfig, options = {}) {
578
- if (!isOptInEnabled(pluginConfig, 'allowClawvaultExec')) {
579
- return {
580
- success: false,
581
- skipped: true,
582
- output: 'ClawVault CLI execution is disabled. Set allowClawvaultExec=true to enable.',
583
- code: 0
584
- };
585
- }
586
-
587
- const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000;
588
- const executablePath = resolveExecutablePath(CLAWVAULT_EXECUTABLE, {
589
- explicitPath: getConfiguredExecutablePath(pluginConfig)
590
- });
591
- if (!executablePath) {
592
- return {
593
- success: false,
594
- output: 'Unable to resolve clawvault executable path. Set clawvaultBinaryPath to an absolute executable path.',
595
- code: 1
596
- };
597
- }
598
-
599
- const expectedSha256 = getConfiguredExecutableSha256(pluginConfig);
600
- const integrityResult = verifyExecutableIntegrity(executablePath, expectedSha256);
601
- if (!integrityResult.ok) {
602
- return {
603
- success: false,
604
- output: `Executable integrity verification failed for ${executablePath}.`,
605
- code: 1
606
- };
607
- }
608
-
609
- let sanitizedArgs;
610
- try {
611
- sanitizedArgs = sanitizeExecArgs(args);
612
- } catch (err) {
613
- return {
614
- success: false,
615
- output: err?.message || 'Invalid command arguments',
616
- code: 1
617
- };
618
- }
619
-
620
- try {
621
- const output = execFileSync(executablePath, sanitizedArgs, {
622
- encoding: 'utf-8',
623
- timeout: timeoutMs,
624
- stdio: ['pipe', 'pipe', 'pipe'],
625
- shell: false
626
- });
627
- return { success: true, output: output.trim(), code: 0 };
628
- } catch (err) {
629
- return {
630
- success: false,
631
- output: err.stderr?.toString() || err.message || String(err),
632
- code: err.status || 1
633
- };
634
- }
635
- }
636
-
637
- // Parse recovery output safely
638
- function parseRecoveryOutput(output) {
639
- if (!output || typeof output !== 'string') {
640
- return { hadDeath: false, workingOn: null };
641
- }
642
-
643
- const hadDeath = output.includes('Context death detected') ||
644
- output.includes('died') ||
645
- output.includes('⚠️');
646
-
647
- let workingOn = null;
648
- if (hadDeath) {
649
- const lines = output.split('\n');
650
- const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
651
- if (workingOnLine) {
652
- const parts = workingOnLine.split(':');
653
- if (parts.length > 1) {
654
- workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim());
655
- }
656
- }
657
- }
658
-
659
- return { hadDeath, workingOn };
660
- }
661
-
662
- function resolveAgentIdForEvent(event, pluginConfig) {
663
- const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event));
664
- if (fromSessionKey) return fromSessionKey;
665
-
666
- if (allowsEnvAccess(pluginConfig)) {
667
- const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID);
668
- if (fromEnv) return fromEnv;
669
- }
670
-
671
- return 'main';
672
- }
673
-
674
- function runObserverCron(vaultPath, agentId, pluginConfig, options = {}) {
675
- const args = ['observe', '--cron', '--agent', agentId, '-v', vaultPath];
676
- if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) {
677
- args.push('--min-new', String(Math.floor(Number(options.minNewBytes))));
678
- }
679
-
680
- const result = runClawvault(args, pluginConfig, { timeoutMs: 120000 });
681
- if (result.skipped) {
682
- console.log('[clawvault] Observer cron skipped: allowClawvaultExec is disabled');
683
- return false;
684
- }
685
- if (!result.success) {
686
- console.warn(`[clawvault] Observer cron failed (${options.reason || 'unknown reason'})`);
687
- return false;
688
- }
689
-
690
- if (result.output) {
691
- console.log(`[clawvault] Observer cron: ${result.output}`);
692
- } else {
693
- console.log('[clawvault] Observer cron: complete');
694
- }
695
- return true;
696
- }
697
-
698
- function ensureClawvaultDir(vaultPath) {
699
- const dir = path.join(vaultPath, '.clawvault');
700
- if (!fs.existsSync(dir)) {
701
- fs.mkdirSync(dir, { recursive: true });
702
- }
703
- return dir;
704
- }
705
-
706
- function getFactsFilePath(vaultPath) {
707
- return path.join(ensureClawvaultDir(vaultPath), FACTS_FILE);
708
- }
709
-
710
- function getEntityGraphFilePath(vaultPath) {
711
- return path.join(ensureClawvaultDir(vaultPath), ENTITY_GRAPH_FILE);
712
- }
713
-
714
- function sanitizeFactText(value, maxLength = MAX_FACT_TEXT_LENGTH) {
715
- if (typeof value !== 'string') return '';
716
- return value
717
- .replace(/[\x00-\x1f\x7f]/g, ' ')
718
- .replace(/\s+/g, ' ')
719
- .trim()
720
- .slice(0, maxLength);
721
- }
722
-
723
- function normalizeEntityLabel(value) {
724
- const cleaned = sanitizeFactText(value, 120).replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '');
725
- if (!cleaned) return 'User';
726
- if (/^(i|me|my|mine|we|us|our|ours)$/i.test(cleaned)) {
727
- return 'User';
728
- }
729
- return cleaned;
730
- }
731
-
732
- function normalizeEntityToken(value) {
733
- const normalized = sanitizeFactText(value, 120)
734
- .toLowerCase()
735
- .replace(/[^a-z0-9]+/g, '_')
736
- .replace(/^_+|_+$/g, '');
737
- return normalized || 'user';
738
- }
739
-
740
- function normalizeFactValue(value) {
741
- return sanitizeFactText(String(value ?? ''), 260)
742
- .replace(/^[,:;\s-]+|[,:;\s-]+$/g, '')
743
- .trim();
744
- }
745
-
746
- function normalizeFactRelation(value) {
747
- if (typeof value !== 'string') return '';
748
- return value
749
- .trim()
750
- .toLowerCase()
751
- .replace(/[^a-z0-9_]+/g, '_')
752
- .replace(/^_+|_+$/g, '');
753
- }
754
-
755
- function clampConfidence(value, fallback = 0.7) {
756
- const numeric = Number(value);
757
- if (!Number.isFinite(numeric)) return fallback;
758
- if (numeric < 0) return 0;
759
- if (numeric > 1) return 1;
760
- return numeric;
761
- }
762
-
763
- function toIsoTimestamp(value) {
764
- const date = value instanceof Date ? value : new Date(value);
765
- if (Number.isNaN(date.getTime())) {
766
- return new Date().toISOString();
767
- }
768
- return date.toISOString();
769
- }
770
-
771
- function slugifyForId(value) {
772
- const base = sanitizeFactText(String(value ?? ''), 180)
773
- .toLowerCase()
774
- .replace(/[^a-z0-9]+/g, '-')
775
- .replace(/^-+|-+$/g, '');
776
- if (!base) return 'unknown';
777
- if (base.length <= 80) return base;
778
- const hash = createHash('sha1').update(base).digest('hex').slice(0, 10);
779
- return `${base.slice(0, 64)}-${hash}`;
780
- }
781
-
782
- function isExclusiveFactRelation(relation) {
783
- return EXCLUSIVE_FACT_RELATIONS.has(relation) || relation.startsWith('favorite_');
784
- }
785
-
786
- function createFactRecord({
787
- entity,
788
- relation,
789
- value,
790
- validFrom,
791
- confidence,
792
- category,
793
- source,
794
- rawText
795
- }) {
796
- const relationToken = normalizeFactRelation(relation);
797
- const valueToken = normalizeFactValue(value);
798
- if (!relationToken || !valueToken) return null;
799
-
800
- const entityLabel = normalizeEntityLabel(entity || 'User');
801
- const entityNorm = normalizeEntityToken(entityLabel);
802
- const factSource = sanitizeFactText(source || 'hook');
803
- const factRawText = sanitizeFactText(rawText || valueToken);
804
- const categoryToken = sanitizeFactText(category || 'facts', 40).toLowerCase() || 'facts';
805
-
806
- return {
807
- id: randomUUID(),
808
- entity: entityLabel,
809
- entityNorm,
810
- relation: relationToken,
811
- value: valueToken,
812
- validFrom: toIsoTimestamp(validFrom),
813
- validUntil: null,
814
- confidence: clampConfidence(confidence, 0.7),
815
- category: categoryToken,
816
- source: factSource,
817
- rawText: factRawText
818
- };
819
- }
820
-
821
- function appendPatternFacts(target, sentence, pattern, options = {}) {
822
- pattern.lastIndex = 0;
823
- let match;
824
-
825
- while ((match = pattern.exec(sentence)) !== null) {
826
- const relation = options.relation;
827
- const category = options.category || 'facts';
828
- const confidence = options.confidence ?? 0.7;
829
- const value = typeof options.value === 'function' ? options.value(match) : match[2];
830
- const entity = typeof options.entity === 'function'
831
- ? options.entity(match)
832
- : options.entity || match[1] || 'User';
833
-
834
- const record = createFactRecord({
835
- entity,
836
- relation,
837
- value,
838
- validFrom: options.validFrom,
839
- confidence,
840
- category,
841
- source: options.source,
842
- rawText: sentence
843
- });
844
-
845
- if (record) {
846
- target.push(record);
847
- }
848
- }
849
- }
850
-
851
- function extractFactsFromSentence(sentence, options) {
852
- const source = options.source || 'hook:event';
853
- const validFrom = options.validFrom || new Date().toISOString();
854
- const facts = [];
855
- const subjectPattern = '([A-Za-z][a-z]+(?:\\s+[A-Za-z][a-z]+)?|i|we)';
856
-
857
- appendPatternFacts(
858
- facts,
859
- sentence,
860
- new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?prefer(?:s|red|ring)?\\s+([^.;!?]+)`, 'gi'),
861
- { relation: 'favorite_preference', category: 'preferences', confidence: 0.86, source, validFrom }
862
- );
863
-
864
- appendPatternFacts(
865
- facts,
866
- sentence,
867
- new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?like(?:s|d)?\\s+([^.;!?]+)`, 'gi'),
868
- { relation: 'favorite_preference', category: 'preferences', confidence: 0.8, source, validFrom }
869
- );
870
-
871
- appendPatternFacts(
872
- facts,
873
- sentence,
874
- new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?(?:hate|dislike(?:s|d)?)\\s+([^.;!?]+)`, 'gi'),
875
- { relation: 'dislikes', category: 'preferences', confidence: 0.84, source, validFrom }
876
- );
877
-
878
- appendPatternFacts(
879
- facts,
880
- sentence,
881
- new RegExp(`\\b${subjectPattern}\\s+(?:am|is|are)?\\s*allergic\\s+to\\s+([^.;!?]+)`, 'gi'),
882
- { relation: 'allergic_to', category: 'preferences', confidence: 0.92, source, validFrom }
883
- );
884
-
885
- appendPatternFacts(
886
- facts,
887
- sentence,
888
- new RegExp(`\\b${subjectPattern}\\s+(?:work|works|working)\\s+at\\s+([^.;!?]+)`, 'gi'),
889
- { relation: 'works_at', category: 'facts', confidence: 0.92, source, validFrom }
890
- );
891
-
892
- appendPatternFacts(
893
- facts,
894
- sentence,
895
- new RegExp(`\\b${subjectPattern}\\s+(?:live|lives|living)\\s+in\\s+([^.;!?]+)`, 'gi'),
896
- { relation: 'lives_in', category: 'facts', confidence: 0.9, source, validFrom }
897
- );
898
-
899
- appendPatternFacts(
900
- facts,
901
- sentence,
902
- new RegExp(`\\b${subjectPattern}\\s+(?:am|is|are)\\s+(\\d{1,3})\\s*(?:years?\\s*old)?\\b`, 'gi'),
903
- {
904
- relation: 'age',
905
- category: 'facts',
906
- confidence: 0.92,
907
- source,
908
- validFrom,
909
- value: (match) => match[2]
910
- }
911
- );
912
-
913
- appendPatternFacts(
914
- facts,
915
- sentence,
916
- new RegExp(`\\b${subjectPattern}\\s+bought\\s+([^.;!?]+)`, 'gi'),
917
- { relation: 'bought', category: 'facts', confidence: 0.86, source, validFrom }
918
- );
919
-
920
- appendPatternFacts(
921
- facts,
922
- sentence,
923
- new RegExp(`\\b${subjectPattern}\\s+spent\\s+\\$?(\\d+(?:\\.\\d{1,2})?)(?:\\s*(?:usd|dollars?))?(?:\\s+on\\s+([^.;!?]+))?`, 'gi'),
924
- {
925
- relation: 'spent',
926
- category: 'facts',
927
- confidence: 0.9,
928
- source,
929
- validFrom,
930
- value: (match) => {
931
- const amount = match[2] ? `$${match[2]}` : '';
932
- const onWhat = normalizeFactValue(match[3] || '');
933
- return onWhat ? `${amount} on ${onWhat}` : amount;
934
- }
935
- }
936
- );
937
-
938
- appendPatternFacts(
939
- facts,
940
- sentence,
941
- new RegExp(`\\b${subjectPattern}\\s+(?:decided|chose)\\s+(?:to\\s+|on\\s+)?([^.;!?]+)`, 'gi'),
942
- { relation: 'decided', category: 'decisions', confidence: 0.88, source, validFrom }
943
- );
944
-
945
- appendPatternFacts(
946
- facts,
947
- sentence,
948
- /\bmy\s+partner\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi,
949
- { relation: 'partner_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] }
950
- );
951
-
952
- appendPatternFacts(
953
- facts,
954
- sentence,
955
- /\b([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\s+is\s+my\s+partner\b/gi,
956
- { relation: 'partner_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] }
957
- );
958
-
959
- appendPatternFacts(
960
- facts,
961
- sentence,
962
- /\bmy\s+dog\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi,
963
- { relation: 'dog_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] }
964
- );
965
-
966
- appendPatternFacts(
967
- facts,
968
- sentence,
969
- /\bmy\s+(?:mom|mother|dad|father|parent)\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi,
970
- { relation: 'parent_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] }
971
- );
972
-
973
- const deduped = [];
974
- const seen = new Set();
975
- for (const fact of facts) {
976
- const dedupeKey = `${fact.entityNorm}|${fact.relation}|${normalizeFactValue(fact.value).toLowerCase()}`;
977
- if (seen.has(dedupeKey)) continue;
978
- seen.add(dedupeKey);
979
- deduped.push(fact);
980
- }
981
-
982
- return deduped;
983
- }
984
-
985
- function splitObservedTextIntoSentences(text) {
986
- return sanitizeFactText(text, 6000)
987
- .split(FACT_SENTENCE_SPLIT_RE)
988
- .map((part) => sanitizeFactText(part))
989
- .filter((part) => part.length >= 8);
990
- }
991
-
992
- function collectTextsFromMessageLike(target, value, depth = 0) {
993
- if (depth > 3 || value === null || value === undefined) return;
994
-
995
- if (typeof value === 'string') {
996
- const text = sanitizeFactText(value, 4000);
997
- if (text) target.push(text);
998
- return;
999
- }
1000
-
1001
- if (Array.isArray(value)) {
1002
- for (const entry of value) {
1003
- collectTextsFromMessageLike(target, entry, depth + 1);
1004
- }
1005
- return;
1006
- }
1007
-
1008
- if (typeof value !== 'object') return;
1009
-
1010
- const direct = extractTextFromMessage(value);
1011
- if (direct) {
1012
- target.push(sanitizeFactText(direct, 4000));
1013
- }
1014
-
1015
- const directKeys = ['text', 'message', 'content', 'rawText', 'observedText', 'observation', 'prompt'];
1016
- for (const key of directKeys) {
1017
- if (typeof value[key] === 'string') {
1018
- target.push(sanitizeFactText(value[key], 4000));
1019
- }
1020
- }
1021
-
1022
- const nestedKeys = ['messages', 'history', 'entries', 'items', 'observations', 'events', 'payload', 'context'];
1023
- for (const key of nestedKeys) {
1024
- if (value[key] !== undefined) {
1025
- collectTextsFromMessageLike(target, value[key], depth + 1);
1026
- }
1027
- }
1028
- }
1029
-
1030
- function collectObservedTextsForFactExtraction(event) {
1031
- const collected = [];
1032
-
1033
- const directStringCandidates = [
1034
- event?.text,
1035
- event?.message,
1036
- event?.content,
1037
- event?.rawText,
1038
- event?.context?.text,
1039
- event?.context?.message,
1040
- event?.context?.content,
1041
- event?.context?.rawText,
1042
- event?.context?.initialPrompt
1043
- ];
1044
-
1045
- for (const candidate of directStringCandidates) {
1046
- if (typeof candidate === 'string') {
1047
- const text = sanitizeFactText(candidate, 4000);
1048
- if (text) collected.push(text);
1049
- }
1050
- }
1051
-
1052
- const structuredCandidates = [
1053
- event?.messages,
1054
- event?.context?.messages,
1055
- event?.context?.history,
1056
- event?.context?.initialMessages,
1057
- event?.context?.memoryFlush,
1058
- event?.context?.flush,
1059
- event?.observations,
1060
- event?.context?.observations,
1061
- event?.payload?.messages,
1062
- event?.payload?.events
1063
- ];
1064
-
1065
- for (const candidate of structuredCandidates) {
1066
- collectTextsFromMessageLike(collected, candidate);
1067
- }
1068
-
1069
- const deduped = [];
1070
- const seen = new Set();
1071
- for (const item of collected) {
1072
- const normalized = sanitizeFactText(item, 4000);
1073
- if (!normalized) continue;
1074
- if (seen.has(normalized)) continue;
1075
- seen.add(normalized);
1076
- deduped.push(normalized);
1077
- }
1078
- return deduped;
1079
- }
1080
-
1081
- function extractFactsFromObservedText(observedTexts, options) {
1082
- const facts = [];
1083
- const globalSeen = new Set();
1084
- for (const text of observedTexts) {
1085
- for (const sentence of splitObservedTextIntoSentences(text)) {
1086
- const extracted = extractFactsFromSentence(sentence, options);
1087
- for (const fact of extracted) {
1088
- const dedupeKey = `${fact.entityNorm}|${fact.relation}|${normalizeFactValue(fact.value).toLowerCase()}`;
1089
- if (globalSeen.has(dedupeKey)) continue;
1090
- globalSeen.add(dedupeKey);
1091
- facts.push(fact);
1092
- }
1093
- }
1094
- }
1095
- return facts;
1096
- }
1097
-
1098
- function normalizeStoredFact(raw) {
1099
- if (!raw || typeof raw !== 'object') return null;
1100
- const relation = normalizeFactRelation(raw.relation);
1101
- const value = normalizeFactValue(raw.value);
1102
- if (!relation || !value) return null;
1103
-
1104
- const entity = normalizeEntityLabel(raw.entity || raw.entityNorm || 'User');
1105
- const entityNorm = normalizeEntityToken(raw.entityNorm || entity);
1106
- const validFrom = toIsoTimestamp(raw.validFrom || new Date().toISOString());
1107
- let validUntil = null;
1108
- if (typeof raw.validUntil === 'string' && raw.validUntil.trim()) {
1109
- validUntil = toIsoTimestamp(raw.validUntil);
1110
- }
1111
-
1112
- const idBase = `${entityNorm}|${relation}|${value}|${validFrom}`;
1113
- const fallbackId = createHash('sha1').update(idBase).digest('hex').slice(0, 16);
1114
-
1115
- return {
1116
- id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : fallbackId,
1117
- entity,
1118
- entityNorm,
1119
- relation,
1120
- value,
1121
- validFrom,
1122
- validUntil,
1123
- confidence: clampConfidence(raw.confidence, 0.7),
1124
- category: sanitizeFactText(raw.category || 'facts', 40).toLowerCase() || 'facts',
1125
- source: sanitizeFactText(raw.source || 'hook', 120) || 'hook',
1126
- rawText: sanitizeFactText(raw.rawText || value, MAX_FACT_TEXT_LENGTH)
1127
- };
1128
- }
1129
-
1130
- function readFactsFromVault(vaultPath) {
1131
- const factsPath = getFactsFilePath(vaultPath);
1132
- if (!fs.existsSync(factsPath)) {
1133
- return [];
1134
- }
1135
-
1136
- try {
1137
- const lines = fs.readFileSync(factsPath, 'utf-8')
1138
- .split(/\r?\n/)
1139
- .map((line) => line.trim())
1140
- .filter(Boolean);
1141
- const facts = [];
1142
- for (const line of lines) {
1143
- try {
1144
- const parsed = JSON.parse(line);
1145
- const normalized = normalizeStoredFact(parsed);
1146
- if (normalized) facts.push(normalized);
1147
- } catch {
1148
- // Skip malformed lines and keep processing.
1149
- }
1150
- }
1151
- return facts;
1152
- } catch {
1153
- return [];
1154
- }
1155
- }
1156
-
1157
- function writeFactsToVault(vaultPath, facts) {
1158
- const factsPath = getFactsFilePath(vaultPath);
1159
- const lines = facts.map((fact) => JSON.stringify(fact));
1160
- const payload = lines.length > 0 ? `${lines.join('\n')}\n` : '';
1161
- fs.writeFileSync(factsPath, payload, 'utf-8');
1162
- }
1163
-
1164
- function mergeFactsWithConflictResolution(existingFacts, incomingFacts) {
1165
- const merged = [...existingFacts];
1166
- let added = 0;
1167
- let superseded = 0;
1168
- let changed = false;
1169
-
1170
- for (const incoming of incomingFacts) {
1171
- const activeSameRelation = merged.filter((fact) =>
1172
- fact.entityNorm === incoming.entityNorm
1173
- && fact.relation === incoming.relation
1174
- && !fact.validUntil
1175
- );
1176
-
1177
- const incomingValue = normalizeFactValue(incoming.value).toLowerCase();
1178
- const hasExactActiveMatch = activeSameRelation.some((fact) =>
1179
- normalizeFactValue(fact.value).toLowerCase() === incomingValue
1180
- );
1181
- if (hasExactActiveMatch) {
1182
- continue;
1183
- }
1184
-
1185
- const shouldSupersede = activeSameRelation.some((fact) =>
1186
- normalizeFactValue(fact.value).toLowerCase() !== incomingValue
1187
- );
1188
- if (shouldSupersede || isExclusiveFactRelation(incoming.relation)) {
1189
- for (const fact of activeSameRelation) {
1190
- if (normalizeFactValue(fact.value).toLowerCase() === incomingValue) continue;
1191
- if (!fact.validUntil) {
1192
- fact.validUntil = incoming.validFrom;
1193
- superseded += 1;
1194
- changed = true;
1195
- }
1196
- }
1197
- }
1198
-
1199
- merged.push(incoming);
1200
- added += 1;
1201
- changed = true;
1202
- }
1203
-
1204
- return { facts: merged, added, superseded, changed };
1205
- }
1206
-
1207
- function isTimestampAfter(candidate, reference) {
1208
- const candidateTime = new Date(candidate).getTime();
1209
- const referenceTime = new Date(reference).getTime();
1210
- if (Number.isNaN(candidateTime)) return false;
1211
- if (Number.isNaN(referenceTime)) return true;
1212
- return candidateTime > referenceTime;
1213
- }
1214
-
1215
- function ensureGraphNode(nodesById, descriptor, seenAt) {
1216
- const existing = nodesById.get(descriptor.id);
1217
- if (!existing) {
1218
- nodesById.set(descriptor.id, {
1219
- id: descriptor.id,
1220
- name: descriptor.name,
1221
- displayName: descriptor.displayName,
1222
- type: descriptor.type,
1223
- attributes: descriptor.attributes || {},
1224
- lastSeen: seenAt
1225
- });
1226
- return;
1227
- }
1228
-
1229
- existing.attributes = { ...existing.attributes, ...(descriptor.attributes || {}) };
1230
- if (isTimestampAfter(seenAt, existing.lastSeen)) {
1231
- existing.lastSeen = seenAt;
1232
- }
1233
- }
1234
-
1235
- function inferTargetNodeType(relation) {
1236
- if (relation === 'works_at') return 'organization';
1237
- if (relation === 'lives_in') return 'location';
1238
- if (relation === 'partner_name' || relation === 'parent_name') return 'person';
1239
- if (relation === 'dog_name') return 'pet';
1240
- if (relation === 'age' || relation === 'spent') return 'number';
1241
- if (relation === 'bought') return 'item';
1242
- if (relation === 'decided') return 'decision';
1243
- if (relation === 'allergic_to') return 'substance';
1244
- if (relation === 'favorite_preference' || relation === 'dislikes') return 'preference';
1245
- return 'attribute';
1246
- }
1247
-
1248
- function buildTargetNodeDescriptor(fact) {
1249
- const relation = normalizeFactRelation(fact.relation);
1250
- const value = normalizeFactValue(fact.value);
1251
- if (!relation || !value) return null;
1252
-
1253
- if (ENTITY_TARGET_RELATIONS.has(relation)) {
1254
- const normalizedEntityValue = normalizeEntityToken(value);
1255
- return {
1256
- id: `entity:${slugifyForId(normalizedEntityValue)}`,
1257
- name: normalizedEntityValue,
1258
- displayName: value,
1259
- type: inferTargetNodeType(relation),
1260
- attributes: { relation }
1261
- };
1262
- }
1263
-
1264
- return {
1265
- id: `value:${relation}:${slugifyForId(value)}`,
1266
- name: value.toLowerCase(),
1267
- displayName: value,
1268
- type: inferTargetNodeType(relation),
1269
- attributes: { relation }
1270
- };
1271
- }
1272
-
1273
- function buildEntityGraphFromFacts(facts) {
1274
- const nodesById = new Map();
1275
- const edges = [];
1276
-
1277
- for (const fact of facts) {
1278
- const normalized = normalizeStoredFact(fact);
1279
- if (!normalized) continue;
1280
-
1281
- const sourceNodeId = `entity:${slugifyForId(normalized.entityNorm)}`;
1282
- const seenAt = normalized.validFrom || new Date().toISOString();
1283
- ensureGraphNode(nodesById, {
1284
- id: sourceNodeId,
1285
- name: normalized.entityNorm,
1286
- displayName: normalized.entity,
1287
- type: 'person',
1288
- attributes: { entityNorm: normalized.entityNorm }
1289
- }, seenAt);
1290
-
1291
- const targetNode = buildTargetNodeDescriptor(normalized);
1292
- if (!targetNode) continue;
1293
- ensureGraphNode(nodesById, targetNode, seenAt);
1294
-
1295
- const edgeHashSource = `${normalized.id}|${sourceNodeId}|${targetNode.id}|${normalized.relation}|${normalized.validFrom}`;
1296
- const edgeId = `edge:${createHash('sha1').update(edgeHashSource).digest('hex').slice(0, 18)}`;
1297
-
1298
- edges.push({
1299
- id: edgeId,
1300
- source: sourceNodeId,
1301
- target: targetNode.id,
1302
- relation: normalized.relation,
1303
- validFrom: normalized.validFrom,
1304
- validUntil: normalized.validUntil,
1305
- confidence: clampConfidence(normalized.confidence, 0.7)
1306
- });
1307
- }
1308
-
1309
- const nodes = [...nodesById.values()].sort((a, b) => a.id.localeCompare(b.id));
1310
- const sortedEdges = edges.sort((a, b) => a.id.localeCompare(b.id));
1311
- return {
1312
- version: ENTITY_GRAPH_VERSION,
1313
- nodes,
1314
- edges: sortedEdges
1315
- };
1316
- }
1317
-
1318
- function writeEntityGraphToVault(vaultPath, facts) {
1319
- const graphPath = getEntityGraphFilePath(vaultPath);
1320
- const graph = buildEntityGraphFromFacts(facts);
1321
- fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), 'utf-8');
1322
- }
1323
-
1324
- function persistExtractedFacts(vaultPath, incomingFacts) {
1325
- const existingFacts = readFactsFromVault(vaultPath);
1326
- const normalizedIncomingFacts = incomingFacts
1327
- .map((fact) => normalizeStoredFact(fact))
1328
- .filter(Boolean);
1329
-
1330
- if (normalizedIncomingFacts.length === 0) {
1331
- writeEntityGraphToVault(vaultPath, existingFacts);
1332
- return { facts: existingFacts, added: 0, superseded: 0 };
1333
- }
1334
-
1335
- const { facts, added, superseded, changed } = mergeFactsWithConflictResolution(
1336
- existingFacts,
1337
- normalizedIncomingFacts
1338
- );
1339
-
1340
- if (changed || !fs.existsSync(getFactsFilePath(vaultPath))) {
1341
- writeFactsToVault(vaultPath, facts);
1342
- }
1343
- writeEntityGraphToVault(vaultPath, facts);
1344
- return { facts, added, superseded };
1345
- }
1346
-
1347
- function runFactExtractionForEvent(vaultPath, event, eventLabel) {
1348
- try {
1349
- const observedTexts = collectObservedTextsForFactExtraction(event);
1350
- if (observedTexts.length === 0) {
1351
- console.log(`[clawvault] Fact extraction skipped (${eventLabel}: no observed text)`);
1352
- return;
1353
- }
1354
-
1355
- const validFrom = toIsoTimestamp(extractEventTimestamp(event) || new Date());
1356
- const source = `hook:${eventLabel}`;
1357
- const extracted = extractFactsFromObservedText(observedTexts, { source, validFrom });
1358
-
1359
- if (extracted.length === 0) {
1360
- console.log(`[clawvault] Fact extraction found no matches (${eventLabel})`);
1361
- return;
1362
- }
1363
-
1364
- const { facts, added, superseded } = persistExtractedFacts(vaultPath, extracted);
1365
- console.log(`[clawvault] Fact extraction complete (${eventLabel}): +${added}, superseded ${superseded}, total ${facts.length}`);
1366
- } catch (err) {
1367
- console.warn(`[clawvault] Fact extraction failed (${eventLabel}): ${err?.message || 'unknown error'}`);
1368
- }
1369
- }
1370
-
1371
- function extractEventTimestamp(event) {
1372
- const candidates = [
1373
- event?.timestamp,
1374
- event?.scheduledAt,
1375
- event?.time,
1376
- event?.context?.timestamp,
1377
- event?.context?.scheduledAt
1378
- ];
1379
- for (const candidate of candidates) {
1380
- if (!candidate) continue;
1381
- const parsed = new Date(candidate);
1382
- if (!Number.isNaN(parsed.getTime())) {
1383
- return parsed;
1384
- }
1385
- }
1386
- return null;
1387
- }
1388
-
1389
- function isSundayMidnightUtc(date) {
1390
- return date.getUTCDay() === 0 && date.getUTCHours() === 0 && date.getUTCMinutes() === 0;
1391
- }
1392
-
1393
- async function handleWeeklyReflect(event) {
1394
- const pluginConfig = extractPluginConfig(event);
1395
- if (!isOptInEnabled(pluginConfig, 'enableWeeklyReflection', 'weeklyReflection')) {
1396
- return;
1397
- }
1398
-
1399
- const vaultPath = findVaultPath(event, pluginConfig);
1400
- if (!vaultPath) {
1401
- console.log('[clawvault] No vault found, skipping weekly reflection');
1402
- return;
1403
- }
1404
-
1405
- const timestamp = extractEventTimestamp(event) || new Date();
1406
- if (!isSundayMidnightUtc(timestamp)) {
1407
- console.log('[clawvault] Weekly reflect skipped (not Sunday midnight UTC)');
1408
- return;
1409
- }
1410
-
1411
- const result = runClawvault(['reflect', '-v', vaultPath], pluginConfig, { timeoutMs: 120000 });
1412
- if (result.skipped) {
1413
- console.log('[clawvault] Weekly reflection skipped: allowClawvaultExec is disabled');
1414
- return;
1415
- }
1416
- if (!result.success) {
1417
- console.warn('[clawvault] Weekly reflection failed');
1418
- return;
1419
- }
1420
- console.log('[clawvault] Weekly reflection complete');
1421
- }
1422
-
1423
- // Handle gateway startup - check for context death
1424
- async function handleStartup(event) {
1425
- const pluginConfig = extractPluginConfig(event);
1426
- if (!isOptInEnabled(pluginConfig, 'enableStartupRecovery')) {
1427
- return;
1428
- }
1429
-
1430
- const vaultPath = findVaultPath(event, pluginConfig);
1431
- if (!vaultPath) {
1432
- console.log('[clawvault] No vault found, skipping recovery check');
1433
- return;
1434
- }
1435
-
1436
- console.log(`[clawvault] Checking for context death`);
1437
-
1438
- // Pass vault path as separate argument (not interpolated)
1439
- const result = runClawvault(['recover', '--clear', '-v', vaultPath], pluginConfig);
1440
- if (result.skipped) {
1441
- console.log('[clawvault] Recovery check skipped: allowClawvaultExec is disabled');
1442
- return;
1443
- }
1444
-
1445
- if (!result.success) {
1446
- console.warn('[clawvault] Recovery check failed');
1447
- return;
1448
- }
1449
-
1450
- const { hadDeath, workingOn } = parseRecoveryOutput(result.output);
1451
-
1452
- if (hadDeath) {
1453
- // Build safe alert message with sanitized content
1454
- const alertParts = ['[ClawVault] Context death detected.'];
1455
- if (workingOn) {
1456
- alertParts.push(`Last working on: ${workingOn}`);
1457
- }
1458
- alertParts.push('Run `clawvault wake` for full recovery context.');
1459
-
1460
- const alertMsg = alertParts.join(' ');
1461
-
1462
- // Inject into event messages if available
1463
- if (injectSystemMessage(event, alertMsg)) {
1464
- console.warn('[clawvault] Context death detected, alert injected');
1465
- }
1466
- } else {
1467
- console.log('[clawvault] Clean startup - no context death');
1468
- }
1469
- }
1470
-
1471
- // Handle /new command - auto-checkpoint before reset
1472
- async function handleNew(event) {
1473
- const pluginConfig = extractPluginConfig(event);
1474
- const autoCheckpointEnabled = isOptInEnabled(pluginConfig, 'enableAutoCheckpoint', 'autoCheckpoint');
1475
- const observerOnNewEnabled = isOptInEnabled(pluginConfig, 'enableObserveOnNew');
1476
- const factExtractionEnabled = isOptInEnabled(pluginConfig, 'enableFactExtraction');
1477
- if (!autoCheckpointEnabled && !observerOnNewEnabled && !factExtractionEnabled) {
1478
- return;
1479
- }
1480
-
1481
- const vaultPath = findVaultPath(event, pluginConfig);
1482
- if (!vaultPath) {
1483
- console.log('[clawvault] No vault found, skipping auto-checkpoint');
1484
- return;
1485
- }
1486
-
1487
- // Sanitize session info for checkpoint
1488
- const sessionKey = typeof event.sessionKey === 'string'
1489
- ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100)
1490
- : 'unknown';
1491
- const source = typeof event.context?.commandSource === 'string'
1492
- ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50)
1493
- : 'cli';
1494
-
1495
- if (autoCheckpointEnabled) {
1496
- console.log('[clawvault] Auto-checkpoint before /new');
1497
- const result = runClawvault([
1498
- 'checkpoint',
1499
- '--working-on', `Session reset via /new from ${source}`,
1500
- '--focus', `Pre-reset checkpoint, session: ${sessionKey}`,
1501
- '-v', vaultPath
1502
- ], pluginConfig);
1503
-
1504
- if (result.skipped) {
1505
- console.log('[clawvault] Auto-checkpoint skipped: allowClawvaultExec is disabled');
1506
- } else if (result.success) {
1507
- console.log('[clawvault] Auto-checkpoint created');
1508
- } else {
1509
- console.warn('[clawvault] Auto-checkpoint failed');
1510
- }
1511
- }
1512
-
1513
- const agentId = resolveAgentIdForEvent(event, pluginConfig);
1514
- if (observerOnNewEnabled) {
1515
- runObserverCron(vaultPath, agentId, pluginConfig, {
1516
- minNewBytes: 1,
1517
- reason: 'command:new flush'
1518
- });
1519
- }
1520
- if (factExtractionEnabled) {
1521
- runFactExtractionForEvent(vaultPath, event, 'command:new');
1522
- }
1523
- }
1524
-
1525
- // Handle session start - inject dynamic context for first prompt
1526
- async function handleSessionStart(event) {
1527
- const pluginConfig = extractPluginConfig(event);
1528
- if (!isOptInEnabled(pluginConfig, 'enableSessionContextInjection')) {
1529
- return;
1530
- }
1531
-
1532
- const vaultPath = findVaultPath(event, pluginConfig);
1533
- if (!vaultPath) {
1534
- console.log('[clawvault] No vault found, skipping context injection');
1535
- return;
1536
- }
1537
-
1538
- const sessionKey = extractSessionKey(event);
1539
- const prompt = extractInitialPrompt(event);
1540
- let recapEntries = [];
1541
- let memoryEntries = [];
1542
-
1543
- if (sessionKey) {
1544
- console.log('[clawvault] Fetching session recap for context restoration');
1545
- const recapArgs = ['session-recap', sessionKey, '--format', 'json'];
1546
- const agentId = extractAgentIdFromSessionKey(sessionKey);
1547
- if (agentId) {
1548
- recapArgs.push('--agent', agentId);
1549
- }
1550
-
1551
- const recapResult = runClawvault(recapArgs, pluginConfig);
1552
- if (recapResult.skipped) {
1553
- console.log('[clawvault] Session recap skipped: allowClawvaultExec is disabled');
1554
- }
1555
- if (recapResult.success) {
1556
- recapEntries = parseSessionRecapJson(recapResult.output);
1557
- } else if (!recapResult.skipped) {
1558
- console.warn('[clawvault] Session recap lookup failed');
1559
- }
1560
- } else {
1561
- console.log('[clawvault] No session key found, skipping session recap');
1562
- }
1563
-
1564
- if (prompt) {
1565
- console.log('[clawvault] Fetching vault memories for session start prompt');
1566
- const contextResult = runClawvault([
1567
- 'context',
1568
- prompt,
1569
- '--format', 'json',
1570
- '--profile', 'auto',
1571
- '-v', vaultPath
1572
- ], pluginConfig);
1573
-
1574
- if (contextResult.success) {
1575
- memoryEntries = parseContextJson(contextResult.output);
1576
- } else if (contextResult.skipped) {
1577
- console.log('[clawvault] Context lookup skipped: allowClawvaultExec is disabled');
1578
- } else {
1579
- console.warn('[clawvault] Context lookup failed');
1580
- }
1581
- } else {
1582
- console.log('[clawvault] No initial prompt, skipping vault memory lookup');
1583
- }
1584
-
1585
- if (recapEntries.length === 0 && memoryEntries.length === 0) {
1586
- console.log('[clawvault] No session context available to inject');
1587
- return;
1588
- }
1589
-
1590
- if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) {
1591
- console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`);
1592
- } else {
1593
- console.log('[clawvault] No message array available, skipping injection');
1594
- }
1595
- }
1596
-
1597
- // Handle heartbeat events - cheap stat-based trigger for active observation
1598
- async function handleHeartbeat(event) {
1599
- const pluginConfig = extractPluginConfig(event);
1600
- if (!isOptInEnabled(pluginConfig, 'enableHeartbeatObservation', 'observeOnHeartbeat')) {
1601
- return;
1602
- }
1603
-
1604
- const vaultPath = findVaultPath(event, pluginConfig);
1605
- if (!vaultPath) {
1606
- console.log('[clawvault] No vault found, skipping heartbeat observation check');
1607
- return;
1608
- }
1609
-
1610
- const agentId = resolveAgentIdForEvent(event, pluginConfig);
1611
- if (!shouldObserveActiveSessions(vaultPath, agentId, pluginConfig)) {
1612
- console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold');
1613
- return;
1614
- }
1615
-
1616
- runObserverCron(vaultPath, agentId, pluginConfig, { reason: 'heartbeat threshold crossed' });
1617
- }
1618
-
1619
- // Handle context compaction - force flush any pending session deltas
1620
- async function handleContextCompaction(event) {
1621
- const pluginConfig = extractPluginConfig(event);
1622
- const compactionObserveEnabled = isOptInEnabled(pluginConfig, 'enableCompactionObservation');
1623
- const factExtractionEnabled = isOptInEnabled(pluginConfig, 'enableFactExtraction');
1624
- if (!compactionObserveEnabled && !factExtractionEnabled) {
1625
- return;
1626
- }
1627
-
1628
- const vaultPath = findVaultPath(event, pluginConfig);
1629
- if (!vaultPath) {
1630
- console.log('[clawvault] No vault found, skipping compaction observation');
1631
- return;
1632
- }
1633
-
1634
- const agentId = resolveAgentIdForEvent(event, pluginConfig);
1635
- if (compactionObserveEnabled) {
1636
- runObserverCron(vaultPath, agentId, pluginConfig, {
1637
- minNewBytes: 1,
1638
- reason: 'context compaction'
1639
- });
1640
- }
1641
- if (factExtractionEnabled) {
1642
- runFactExtractionForEvent(vaultPath, event, 'compaction:memoryFlush');
1643
- }
1644
- }
1645
-
1646
- // Main handler - route events
1647
- const handler = async (event) => {
1648
- try {
1649
- if (eventMatches(event, 'gateway', 'startup')) {
1650
- await handleStartup(event);
1651
- return;
1652
- }
1653
-
1654
- if (
1655
- eventMatches(event, 'cron', 'weekly')
1656
- || eventIncludesToken(event, 'cron:weekly')
1657
- ) {
1658
- await handleWeeklyReflect(event);
1659
- return;
1660
- }
1661
-
1662
- if (
1663
- eventMatches(event, 'gateway', 'heartbeat')
1664
- || eventMatches(event, 'session', 'heartbeat')
1665
- || eventIncludesToken(event, 'heartbeat')
1666
- ) {
1667
- await handleHeartbeat(event);
1668
- return;
1669
- }
1670
-
1671
- if (
1672
- eventMatches(event, 'compaction', 'memoryflush')
1673
- || eventMatches(event, 'context', 'compaction')
1674
- || eventMatches(event, 'context', 'compact')
1675
- || eventIncludesToken(event, 'compaction')
1676
- || eventIncludesToken(event, 'memoryflush')
1677
- ) {
1678
- await handleContextCompaction(event);
1679
- return;
1680
- }
1681
-
1682
- if (eventMatches(event, 'command', 'new')) {
1683
- await handleNew(event);
1684
- return;
1685
- }
1686
-
1687
- if (eventMatches(event, 'session', 'start')) {
1688
- await handleSessionStart(event);
1689
- return;
1690
- }
1691
- } catch (err) {
1692
- console.error('[clawvault] Hook error:', err.message || 'unknown error');
1693
- }
1694
- };
1695
-
1696
- export default handler;