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.
- package/CHANGELOG.md +543 -0
- package/LICENSE +21 -0
- package/README.md +26 -26
- package/SKILL.md +369 -0
- package/dist/{chunk-X3SPPUFG.js → chunk-JI7VUQV7.js} +118 -132
- package/dist/{chunk-QYQAGBTM.js → chunk-QUFQBAHP.js} +148 -125
- package/dist/cli/index.js +1 -1
- package/dist/commands/compat.js +1 -1
- package/dist/commands/observe.js +1 -1
- package/dist/commands/status.js +4 -4
- package/dist/index.js +11 -8
- package/dist/openclaw-plugin.js +6 -1
- package/docs/clawhub-security-release-playbook.md +75 -0
- package/docs/getting-started/installation.md +99 -0
- package/docs/openclaw-plugin-usage.md +152 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +26 -8
- package/bin/command-registration.test.js +0 -179
- package/bin/command-runtime.test.js +0 -154
- package/bin/help-contract.test.js +0 -55
- package/bin/register-config-route-commands.test.js +0 -121
- package/bin/register-core-commands.test.js +0 -80
- package/bin/register-kanban-commands.test.js +0 -83
- package/bin/register-project-commands.test.js +0 -206
- package/bin/register-query-commands.test.js +0 -80
- package/bin/register-resilience-commands.test.js +0 -81
- package/bin/register-task-commands.test.js +0 -69
- package/bin/register-template-commands.test.js +0 -87
- package/bin/test-helpers/cli-command-fixtures.js +0 -120
- package/dashboard/lib/graph-diff.test.js +0 -75
- package/dashboard/lib/vault-parser.test.js +0 -254
- package/hooks/clawvault/HOOK.md +0 -130
- package/hooks/clawvault/handler.js +0 -1696
- package/hooks/clawvault/handler.test.js +0 -576
- package/hooks/clawvault/integrity.js +0 -112
- package/hooks/clawvault/integrity.test.js +0 -32
- 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;
|