diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..2684df33 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +autoresearch/ +eval/ +benchmarks/ +hooks/ +testdata/ +tests/ +src/ +examples/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fb98415f..c3b7a5ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [3.5.0] — 2026-03-16 + +### Changed +- Refactored OpenClaw plugin context injection to use in-process `ClawVault.find()` and `buildSessionRecap()` calls (no CLI shelling in pre-prompt recall path), eliminating SQLite lock contention from subprocess access. +- Migrated plugin session lifecycle recovery/checkpoint flows to direct library calls (`recover` / `checkpoint`) while keeping observer execution on CLI with non-blocking `spawn`. +- Completed plugin-first repository cleanup by removing legacy `hooks/clawvault/` artifacts and dead `packages/plugin/` scripts package. + +### Packaging +- Published package metadata now targets plugin-first distribution for OpenClaw extensions. +- Added `.npmignore` exclusions for development-only directories (`autoresearch/`, `eval/`, `benchmarks/`, `hooks/`, `testdata/`, `tests/`, `src/`, `examples/`). + +--- + ## [3.3.0] — 2026-03-11 ### Added diff --git a/hooks/clawvault/HOOK.md b/hooks/clawvault/HOOK.md deleted file mode 100644 index 8fea8af6..00000000 --- a/hooks/clawvault/HOOK.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: clawvault -description: "Context resilience - recovery detection, auto-checkpoint, and session context injection" -metadata: - openclaw: - emoji: "🐘" - events: ["gateway:startup", "gateway:heartbeat", "command:new", "session:start", "compaction:memoryFlush", "cron.weekly"] - requires: - bins: ["clawvault"] ---- - -# ClawVault Hook - -Integrates ClawVault's context death resilience into OpenClaw: - -- **On gateway startup**: Checks for context death, alerts agent -- **On heartbeat**: Runs cheap threshold checks and observes active sessions when needed -- **On /new command**: Auto-checkpoints before session reset -- **On context compaction**: Forces incremental observation flush before context is lost -- **On session start**: Injects relevant vault context for the initial prompt -- **On weekly cron**: Runs `clawvault reflect` every Sunday midnight (UTC) - -## Installation - -```bash -npm install -g clawvault -openclaw hooks install clawvault -openclaw hooks enable clawvault - -# Verify -openclaw hooks list --verbose -openclaw hooks info clawvault -openclaw hooks check -``` - -After enabling, restart your OpenClaw gateway process so hook registration reloads. - -## Requirements - -- ClawVault CLI installed globally -- Vault initialized (`clawvault setup` or `CLAWVAULT_PATH` set) - -## What It Does - -### Gateway Startup - -1. Runs `clawvault recover --clear` -2. If context death detected, injects warning into first agent turn -3. Clears dirty death flag for clean session start - -### Command: /new - -1. Creates automatic checkpoint with session info -2. Captures state even if agent forgot to handoff -3. Ensures continuity across session resets - -### Session Start - -1. Extracts the initial user prompt (`context.initialPrompt` or first user message) -2. Runs `clawvault context "" --format json --profile auto -v ` - - Delegates profile selection to the shared context intent policy (`incident`, `planning`, `handoff`, or `default`) -3. Injects up to 4 relevant context bullets into session messages - -Injection format: - -```text -[ClawVault] Relevant context for this task: -- (<age>): <snippet> -- <title> (<age>): <snippet> -``` - -### Event Compatibility - -The hook accepts canonical OpenClaw events (`gateway:startup`, `gateway:heartbeat`, `command:new`, `session:start`, `compaction:memoryFlush`, `cron.weekly`) and tolerates alias payload shapes (`event`, `eventName`, `name`, `hook`, `trigger`) to remain robust across runtime wrappers. - -## Configuration - -### Plugin Configuration (Recommended) - -Configure the plugin via OpenClaw's config system: - -```bash -# Set vault path -openclaw config set plugins.entries.clawvault.config.vaultPath ~/my-vault - -# View current config -openclaw config get plugins.entries.clawvault -``` - -Available configuration options (all privileged actions are opt-in): - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `vaultPath` | string | (auto-detected) | Path to the ClawVault vault directory | -| `agentVaults` | object | `{}` | Per-agent vault mapping | -| `allowClawvaultExec` | boolean | `false` | Required gate for all `child_process` calls | -| `clawvaultBinaryPath` | string | (PATH lookup) | Optional absolute path to `clawvault` binary | -| `clawvaultBinarySha256` | string | (unset) | Optional SHA-256 executable integrity check | -| `allowEnvAccess` | boolean | `false` | Allow env fallbacks (`OPENCLAW_*`, `CLAWVAULT_PATH`) | -| `enableStartupRecovery` | boolean | `false` | Enable `gateway:startup` recovery check | -| `enableSessionContextInjection` | boolean | `false` | Enable `session:start` recap/context injection | -| `enableAutoCheckpoint` | boolean | `false` | Enable checkpoint on `command:new` | -| `enableObserveOnNew` | boolean | `false` | Enable observer flush on `command:new` | -| `enableHeartbeatObservation` | boolean | `false` | Enable heartbeat-driven observation | -| `enableCompactionObservation` | boolean | `false` | Enable observer flush on compaction | -| `enableWeeklyReflection` | boolean | `false` | Enable weekly reflection cron | -| `enableFactExtraction` | boolean | `false` | Enable local fact extraction/entity graph updates | -| `autoCheckpoint` | boolean | `false` | Deprecated alias for `enableAutoCheckpoint` | -| `contextProfile` | string | `"auto"` | Default context profile (`default`, `planning`, `incident`, `handoff`, `auto`) | -| `maxContextResults` | integer | `4` | Maximum context results to inject on session start | -| `observeOnHeartbeat` | boolean | `false` | Deprecated alias for `enableHeartbeatObservation` | -| `weeklyReflection` | boolean | `false` | Deprecated alias for `enableWeeklyReflection` | - -Security details and threat model: see [SECURITY.md](../../SECURITY.md). - -### Vault Path Resolution - -When `allowEnvAccess=true`, the hook resolves the vault path in this order: - -1. Plugin config (`plugins.entries.clawvault.config.vaultPath` set via `openclaw config`) -2. `OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH` environment variable -3. `CLAWVAULT_PATH` environment variable -4. Walking up from cwd to find `.clawvault.json` -5. Checking `memory/` subdirectory (OpenClaw convention) - -When `allowEnvAccess=false` (default), steps 2 and 3 are skipped. - -### Troubleshooting - -If `openclaw hooks enable clawvault` fails with hook-not-found, run `openclaw hooks install clawvault` first and verify discovery with `openclaw hooks list --verbose`. diff --git a/hooks/clawvault/handler.js b/hooks/clawvault/handler.js deleted file mode 100644 index 93fe1d90..00000000 --- a/hooks/clawvault/handler.js +++ /dev/null @@ -1,1696 +0,0 @@ -/** - * ClawVault OpenClaw Hook - * - * Provides automatic context death resilience: - * - gateway:startup → detect context death, inject recovery info - * - gateway:heartbeat → cheap active-session threshold checks - * - command:new → auto-checkpoint before session reset - * - compaction:memoryFlush → force active-session flush before compaction - * - session:start → inject relevant context for first user prompt - * - * SECURITY: Uses execFileSync (no shell) to prevent command injection - */ - -import { execFileSync } from 'child_process'; -import { createHash, randomUUID } from 'crypto'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - resolveExecutablePath, - sanitizeExecArgs, - verifyExecutableIntegrity -} from './integrity.js'; - -const MAX_CONTEXT_RESULTS = 4; -const MAX_CONTEXT_PROMPT_LENGTH = 500; -const MAX_CONTEXT_SNIPPET_LENGTH = 220; -const MAX_RECAP_RESULTS = 6; -const MAX_RECAP_SNIPPET_LENGTH = 220; -const EVENT_NAME_SEPARATOR_RE = /[.:/]/g; -const OBSERVE_CURSOR_FILE = 'observe-cursors.json'; -const ONE_KIB = 1024; -const ONE_MIB = ONE_KIB * ONE_KIB; -const SMALL_SESSION_THRESHOLD_BYTES = 50 * ONE_KIB; -const MEDIUM_SESSION_THRESHOLD_BYTES = 150 * ONE_KIB; -const LARGE_SESSION_THRESHOLD_BYTES = 300 * ONE_KIB; -const FACTS_FILE = 'facts.jsonl'; -const ENTITY_GRAPH_FILE = 'entity-graph.json'; -const ENTITY_GRAPH_VERSION = 1; -const MAX_FACT_TEXT_LENGTH = 600; -const FACT_SENTENCE_SPLIT_RE = /[.!?]+\s+|\r?\n+/; -const EXCLUSIVE_FACT_RELATIONS = new Set(['lives_in', 'works_at', 'age']); -const ENTITY_TARGET_RELATIONS = new Set(['works_at', 'lives_in', 'partner_name', 'dog_name', 'parent_name']); -const CLAWVAULT_EXECUTABLE = 'clawvault'; - -// Sanitize string for safe display (prevent prompt injection via control chars) -function sanitizeForDisplay(str) { - if (typeof str !== 'string') return ''; - // Remove control characters, limit length, escape markdown - return str - .replace(/[\x00-\x1f\x7f]/g, '') // Remove control chars - .replace(/[`*_~\[\]]/g, '\\$&') // Escape markdown - .slice(0, 200); // Limit length -} - -// Sanitize prompt before passing to CLI command -function sanitizePromptForContext(str) { - if (typeof str !== 'string') return ''; - return str - .replace(/[\x00-\x1f\x7f]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, MAX_CONTEXT_PROMPT_LENGTH); -} - -function sanitizeSessionKey(str) { - if (typeof str !== 'string') return ''; - const trimmed = str.trim(); - if (!/^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/.test(trimmed)) { - return ''; - } - return trimmed.slice(0, 200); -} - -function extractSessionKey(event) { - const candidates = [ - event?.sessionKey, - event?.context?.sessionKey, - event?.session?.key, - event?.context?.session?.key, - event?.metadata?.sessionKey - ]; - - for (const candidate of candidates) { - const key = sanitizeSessionKey(candidate); - if (key) return key; - } - - return ''; -} - -function extractAgentIdFromSessionKey(sessionKey) { - const match = /^agent:([^:]+):/.exec(sessionKey); - if (!match?.[1]) return ''; - const agentId = match[1].trim(); - if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentId)) return ''; - return agentId; -} - -function sanitizeAgentId(agentId) { - if (typeof agentId !== 'string') return ''; - const normalized = agentId.trim(); - if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalized)) return ''; - return normalized; -} - -function normalizeAbsoluteEnvPath(value) { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const resolved = path.resolve(trimmed); - if (!path.isAbsolute(resolved)) return null; - return resolved; -} - -function getOpenClawAgentsDir(pluginConfig) { - if (allowsEnvAccess(pluginConfig)) { - const stateDir = normalizeAbsoluteEnvPath(process.env.OPENCLAW_STATE_DIR); - if (stateDir) { - return path.join(stateDir, 'agents'); - } - - const openClawHome = normalizeAbsoluteEnvPath(process.env.OPENCLAW_HOME); - if (openClawHome) { - return path.join(openClawHome, 'agents'); - } - } - - return path.join(os.homedir(), '.openclaw', 'agents'); -} - -function getObserveCursorPath(vaultPath) { - return path.join(vaultPath, '.clawvault', OBSERVE_CURSOR_FILE); -} - -function loadObserveCursors(vaultPath) { - const cursorPath = getObserveCursorPath(vaultPath); - if (!fs.existsSync(cursorPath)) { - return {}; - } - - try { - const parsed = JSON.parse(fs.readFileSync(cursorPath, 'utf-8')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {}; - } - return parsed; - } catch { - return {}; - } -} - -function getScaledObservationThresholdBytes(fileSizeBytes) { - if (!Number.isFinite(fileSizeBytes) || fileSizeBytes <= 0) { - return SMALL_SESSION_THRESHOLD_BYTES; - } - if (fileSizeBytes < ONE_MIB) { - return SMALL_SESSION_THRESHOLD_BYTES; - } - if (fileSizeBytes <= 5 * ONE_MIB) { - return MEDIUM_SESSION_THRESHOLD_BYTES; - } - return LARGE_SESSION_THRESHOLD_BYTES; -} - -function parseSessionIndex(agentId, pluginConfig) { - const sessionsDir = path.join(getOpenClawAgentsDir(pluginConfig), agentId, 'sessions'); - const sessionsJsonPath = path.join(sessionsDir, 'sessions.json'); - if (!fs.existsSync(sessionsJsonPath)) { - return { sessionsDir, index: {} }; - } - - try { - const parsed = JSON.parse(fs.readFileSync(sessionsJsonPath, 'utf-8')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { sessionsDir, index: {} }; - } - return { sessionsDir, index: parsed }; - } catch { - return { sessionsDir, index: {} }; - } -} - -function shouldObserveActiveSessions(vaultPath, agentId, pluginConfig) { - const cursors = loadObserveCursors(vaultPath); - const { sessionsDir, index } = parseSessionIndex(agentId, pluginConfig); - const entries = Object.entries(index); - if (entries.length === 0) { - return false; - } - - for (const [sessionKey, value] of entries) { - if (!value || typeof value !== 'object') continue; - const sessionId = typeof value.sessionId === 'string' ? value.sessionId.trim() : ''; - if (!/^[a-zA-Z0-9._-]{1,200}$/.test(sessionId)) continue; - - const filePath = path.join(sessionsDir, `${sessionId}.jsonl`); - let stat; - try { - stat = fs.statSync(filePath); - } catch { - continue; - } - if (!stat.isFile()) continue; - - const fileSize = stat.size; - const cursorEntry = cursors[sessionId]; - const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset) - ? Math.max(0, Number(cursorEntry.lastObservedOffset)) - : 0; - const startOffset = previousOffset <= fileSize ? previousOffset : 0; - const newBytes = Math.max(0, fileSize - startOffset); - const thresholdBytes = getScaledObservationThresholdBytes(fileSize); - - if (newBytes >= thresholdBytes) { - console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`); - return true; - } - } - - return false; -} - -function extractTextFromMessage(message) { - if (typeof message === 'string') return message; - if (!message || typeof message !== 'object') return ''; - - const content = message.content ?? message.text ?? message.message; - if (typeof content === 'string') return content; - - if (Array.isArray(content)) { - return content - .map((part) => { - if (typeof part === 'string') return part; - if (!part || typeof part !== 'object') return ''; - if (typeof part.text === 'string') return part.text; - if (typeof part.content === 'string') return part.content; - return ''; - }) - .filter(Boolean) - .join(' '); - } - - return ''; -} - -function isUserMessage(message) { - if (typeof message === 'string') return true; - if (!message || typeof message !== 'object') return false; - const role = typeof message.role === 'string' ? message.role.toLowerCase() : ''; - const type = typeof message.type === 'string' ? message.type.toLowerCase() : ''; - return role === 'user' || role === 'human' || type === 'user'; -} - -function extractInitialPrompt(event) { - const fromContext = sanitizePromptForContext(event?.context?.initialPrompt); - if (fromContext) return fromContext; - - const candidates = [ - event?.context?.messages, - event?.context?.initialMessages, - event?.context?.history, - event?.messages - ]; - - for (const list of candidates) { - if (!Array.isArray(list)) continue; - for (const message of list) { - if (!isUserMessage(message)) continue; - const text = sanitizePromptForContext(extractTextFromMessage(message)); - if (text) return text; - } - } - - return ''; -} - -function truncateSnippet(snippet) { - const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim(); - if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe; - return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`; -} - -function truncateRecapSnippet(snippet) { - const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim(); - if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe; - return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`; -} - -function parseContextJson(output) { - try { - const parsed = JSON.parse(output); - if (!parsed || !Array.isArray(parsed.context)) return []; - - return parsed.context - .slice(0, MAX_CONTEXT_RESULTS) - .map((entry) => ({ - title: sanitizeForDisplay(entry?.title || 'Untitled'), - age: sanitizeForDisplay(entry?.age || 'unknown age'), - snippet: truncateSnippet(entry?.snippet || '') - })) - .filter((entry) => entry.snippet); - } catch { - return []; - } -} - -function parseSessionRecapJson(output) { - try { - const parsed = JSON.parse(output); - if (!parsed || !Array.isArray(parsed.messages)) return []; - - return parsed.messages - .map((entry) => { - if (!entry || typeof entry !== 'object') return null; - const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : ''; - if (role !== 'user' && role !== 'assistant') return null; - const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : ''); - if (!text) return null; - return { - role: role === 'user' ? 'User' : 'Assistant', - text - }; - }) - .filter(Boolean) - .slice(-MAX_RECAP_RESULTS); - } catch { - return []; - } -} - -function formatSessionContextInjection(recapEntries, memoryEntries) { - const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:']; - - if (recapEntries.length === 0) { - lines.push('- No recent user/assistant turns found for this session.'); - } else { - for (const entry of recapEntries) { - lines.push(`- ${entry.role}: ${entry.text}`); - } - } - - lines.push('', 'Relevant memories:'); - if (memoryEntries.length === 0) { - lines.push('- No relevant vault memories found for the current prompt.'); - } else { - for (const entry of memoryEntries) { - lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`); - } - } - - return lines.join('\n'); -} - -function injectSystemMessage(event, message) { - if (!event.messages || !Array.isArray(event.messages)) return false; - - if (event.messages.length === 0) { - event.messages.push(message); - return true; - } - - const first = event.messages[0]; - if (first && typeof first === 'object' && !Array.isArray(first)) { - if ('role' in first || 'content' in first) { - event.messages.push({ role: 'system', content: message }); - return true; - } - if ('type' in first || 'text' in first) { - event.messages.push({ type: 'system', text: message }); - return true; - } - } - - event.messages.push(message); - return true; -} - -function normalizeEventToken(value) { - if (typeof value !== 'string') return ''; - return value - .trim() - .toLowerCase() - .replace(/\s+/g, '') - .replace(EVENT_NAME_SEPARATOR_RE, ':'); -} - -function eventMatches(event, type, action) { - const normalizedExpected = `${normalizeEventToken(type)}:${normalizeEventToken(action)}`; - const normalizedType = normalizeEventToken(event?.type); - const normalizedAction = normalizeEventToken(event?.action); - - if (normalizedType && normalizedAction) { - if (`${normalizedType}:${normalizedAction}` === normalizedExpected) { - return true; - } - } - - const aliases = [ - event?.event, - event?.name, - event?.hook, - event?.trigger, - event?.eventName - ]; - - for (const alias of aliases) { - const normalizedAlias = normalizeEventToken(alias); - if (!normalizedAlias) continue; - if (normalizedAlias === normalizedExpected) { - return true; - } - } - - return false; -} - -function eventIncludesToken(event, token) { - const normalizedToken = normalizeEventToken(token); - if (!normalizedToken) return false; - - const values = [ - event?.type, - event?.action, - event?.event, - event?.name, - event?.hook, - event?.trigger, - event?.eventName - ]; - - return values - .map((value) => normalizeEventToken(value)) - .filter(Boolean) - .some((value) => value.includes(normalizedToken)); -} - -// Validate vault path - must be absolute and exist -function validateVaultPath(vaultPath) { - if (!vaultPath || typeof vaultPath !== 'string') return null; - - // Resolve to absolute path - const resolved = path.resolve(vaultPath); - - // Must be absolute - if (!path.isAbsolute(resolved)) return null; - - // Must exist and be a directory - try { - const stat = fs.statSync(resolved); - if (!stat.isDirectory()) return null; - } catch { - return null; - } - - // Must contain .clawvault.json - const configPath = path.join(resolved, '.clawvault.json'); - if (!fs.existsSync(configPath)) return null; - - return resolved; -} - -// Extract plugin config from event context (set via openclaw config) -function extractPluginConfig(event) { - const candidates = [ - event?.pluginConfig, - event?.context?.pluginConfig, - event?.config?.plugins?.entries?.clawvault?.config, - event?.context?.config?.plugins?.entries?.clawvault?.config, - event?.config?.plugins?.clawvault?.config, - event?.context?.config?.plugins?.clawvault?.config - ]; - - for (const candidate of candidates) { - if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { - return candidate; - } - } - - return {}; -} - -function isOptInEnabled(pluginConfig, ...keys) { - for (const key of keys) { - if (pluginConfig?.[key] === true) return true; - } - return false; -} - -function allowsEnvAccess(pluginConfig) { - return isOptInEnabled(pluginConfig, 'allowEnvAccess'); -} - -function getConfiguredExecutablePath(pluginConfig) { - const value = pluginConfig?.clawvaultBinaryPath; - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed || null; -} - -function getConfiguredExecutableSha256(pluginConfig) { - const value = pluginConfig?.clawvaultBinarySha256; - if (typeof value !== 'string') return null; - const trimmed = value.trim().toLowerCase(); - return trimmed || null; -} - -// Resolve vault path for a specific agent from agentVaults config -function resolveAgentVaultPath(pluginConfig, agentId) { - if (!agentId || typeof agentId !== 'string') return null; - - const agentVaults = pluginConfig?.agentVaults; - if (!agentVaults || typeof agentVaults !== 'object' || Array.isArray(agentVaults)) { - return null; - } - - const agentPath = agentVaults[agentId]; - if (!agentPath || typeof agentPath !== 'string') return null; - - return validateVaultPath(agentPath); -} - -// Find vault by walking up directories -// Supports per-agent vault paths via agentVaults config -function findVaultPath(event, pluginConfig, options = {}) { - // Determine agent ID for per-agent vault resolution - const agentId = options.agentId || resolveAgentIdForEvent(event, pluginConfig); - - // Check agentVaults first (per-agent vault paths) - if (agentId) { - const agentVaultPath = resolveAgentVaultPath(pluginConfig, agentId); - if (agentVaultPath) { - console.log(`[clawvault] Using per-agent vault for ${agentId}: ${agentVaultPath}`); - return agentVaultPath; - } - } - - // Check plugin config vaultPath (fallback for all agents) - if (pluginConfig.vaultPath) { - const validated = validateVaultPath(pluginConfig.vaultPath); - if (validated) return validated; - } - - if (allowsEnvAccess(pluginConfig)) { - // Check OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH env (injected by OpenClaw from plugin config) - if (process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH) { - const validated = validateVaultPath(process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH); - if (validated) return validated; - } - - // Check CLAWVAULT_PATH env - if (process.env.CLAWVAULT_PATH) { - return validateVaultPath(process.env.CLAWVAULT_PATH); - } - } - - // Walk up from cwd - let dir = process.cwd(); - const root = path.parse(dir).root; - - while (dir !== root) { - const validated = validateVaultPath(dir); - if (validated) return validated; - - // Also check memory/ subdirectory (OpenClaw convention) - const memoryDir = path.join(dir, 'memory'); - const memoryValidated = validateVaultPath(memoryDir); - if (memoryValidated) return memoryValidated; - - dir = path.dirname(dir); - } - - return null; -} - -// Run clawvault command safely (no shell) -function runClawvault(args, pluginConfig, options = {}) { - if (!isOptInEnabled(pluginConfig, 'allowClawvaultExec')) { - return { - success: false, - skipped: true, - output: 'ClawVault CLI execution is disabled. Set allowClawvaultExec=true to enable.', - code: 0 - }; - } - - const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000; - const executablePath = resolveExecutablePath(CLAWVAULT_EXECUTABLE, { - explicitPath: getConfiguredExecutablePath(pluginConfig) - }); - if (!executablePath) { - return { - success: false, - output: 'Unable to resolve clawvault executable path. Set clawvaultBinaryPath to an absolute executable path.', - code: 1 - }; - } - - const expectedSha256 = getConfiguredExecutableSha256(pluginConfig); - const integrityResult = verifyExecutableIntegrity(executablePath, expectedSha256); - if (!integrityResult.ok) { - return { - success: false, - output: `Executable integrity verification failed for ${executablePath}.`, - code: 1 - }; - } - - let sanitizedArgs; - try { - sanitizedArgs = sanitizeExecArgs(args); - } catch (err) { - return { - success: false, - output: err?.message || 'Invalid command arguments', - code: 1 - }; - } - - try { - const output = execFileSync(executablePath, sanitizedArgs, { - encoding: 'utf-8', - timeout: timeoutMs, - stdio: ['pipe', 'pipe', 'pipe'], - shell: false - }); - return { success: true, output: output.trim(), code: 0 }; - } catch (err) { - return { - success: false, - output: err.stderr?.toString() || err.message || String(err), - code: err.status || 1 - }; - } -} - -// Parse recovery output safely -function parseRecoveryOutput(output) { - if (!output || typeof output !== 'string') { - return { hadDeath: false, workingOn: null }; - } - - const hadDeath = output.includes('Context death detected') || - output.includes('died') || - output.includes('⚠️'); - - let workingOn = null; - if (hadDeath) { - const lines = output.split('\n'); - const workingOnLine = lines.find(l => l.toLowerCase().includes('working on')); - if (workingOnLine) { - const parts = workingOnLine.split(':'); - if (parts.length > 1) { - workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim()); - } - } - } - - return { hadDeath, workingOn }; -} - -function resolveAgentIdForEvent(event, pluginConfig) { - const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event)); - if (fromSessionKey) return fromSessionKey; - - if (allowsEnvAccess(pluginConfig)) { - const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID); - if (fromEnv) return fromEnv; - } - - return 'main'; -} - -function runObserverCron(vaultPath, agentId, pluginConfig, options = {}) { - const args = ['observe', '--cron', '--agent', agentId, '-v', vaultPath]; - if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) { - args.push('--min-new', String(Math.floor(Number(options.minNewBytes)))); - } - - const result = runClawvault(args, pluginConfig, { timeoutMs: 120000 }); - if (result.skipped) { - console.log('[clawvault] Observer cron skipped: allowClawvaultExec is disabled'); - return false; - } - if (!result.success) { - console.warn(`[clawvault] Observer cron failed (${options.reason || 'unknown reason'})`); - return false; - } - - if (result.output) { - console.log(`[clawvault] Observer cron: ${result.output}`); - } else { - console.log('[clawvault] Observer cron: complete'); - } - return true; -} - -function ensureClawvaultDir(vaultPath) { - const dir = path.join(vaultPath, '.clawvault'); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - return dir; -} - -function getFactsFilePath(vaultPath) { - return path.join(ensureClawvaultDir(vaultPath), FACTS_FILE); -} - -function getEntityGraphFilePath(vaultPath) { - return path.join(ensureClawvaultDir(vaultPath), ENTITY_GRAPH_FILE); -} - -function sanitizeFactText(value, maxLength = MAX_FACT_TEXT_LENGTH) { - if (typeof value !== 'string') return ''; - return value - .replace(/[\x00-\x1f\x7f]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, maxLength); -} - -function normalizeEntityLabel(value) { - const cleaned = sanitizeFactText(value, 120).replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, ''); - if (!cleaned) return 'User'; - if (/^(i|me|my|mine|we|us|our|ours)$/i.test(cleaned)) { - return 'User'; - } - return cleaned; -} - -function normalizeEntityToken(value) { - const normalized = sanitizeFactText(value, 120) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, ''); - return normalized || 'user'; -} - -function normalizeFactValue(value) { - return sanitizeFactText(String(value ?? ''), 260) - .replace(/^[,:;\s-]+|[,:;\s-]+$/g, '') - .trim(); -} - -function normalizeFactRelation(value) { - if (typeof value !== 'string') return ''; - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, '_') - .replace(/^_+|_+$/g, ''); -} - -function clampConfidence(value, fallback = 0.7) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - if (numeric < 0) return 0; - if (numeric > 1) return 1; - return numeric; -} - -function toIsoTimestamp(value) { - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) { - return new Date().toISOString(); - } - return date.toISOString(); -} - -function slugifyForId(value) { - const base = sanitizeFactText(String(value ?? ''), 180) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - if (!base) return 'unknown'; - if (base.length <= 80) return base; - const hash = createHash('sha1').update(base).digest('hex').slice(0, 10); - return `${base.slice(0, 64)}-${hash}`; -} - -function isExclusiveFactRelation(relation) { - return EXCLUSIVE_FACT_RELATIONS.has(relation) || relation.startsWith('favorite_'); -} - -function createFactRecord({ - entity, - relation, - value, - validFrom, - confidence, - category, - source, - rawText -}) { - const relationToken = normalizeFactRelation(relation); - const valueToken = normalizeFactValue(value); - if (!relationToken || !valueToken) return null; - - const entityLabel = normalizeEntityLabel(entity || 'User'); - const entityNorm = normalizeEntityToken(entityLabel); - const factSource = sanitizeFactText(source || 'hook'); - const factRawText = sanitizeFactText(rawText || valueToken); - const categoryToken = sanitizeFactText(category || 'facts', 40).toLowerCase() || 'facts'; - - return { - id: randomUUID(), - entity: entityLabel, - entityNorm, - relation: relationToken, - value: valueToken, - validFrom: toIsoTimestamp(validFrom), - validUntil: null, - confidence: clampConfidence(confidence, 0.7), - category: categoryToken, - source: factSource, - rawText: factRawText - }; -} - -function appendPatternFacts(target, sentence, pattern, options = {}) { - pattern.lastIndex = 0; - let match; - - while ((match = pattern.exec(sentence)) !== null) { - const relation = options.relation; - const category = options.category || 'facts'; - const confidence = options.confidence ?? 0.7; - const value = typeof options.value === 'function' ? options.value(match) : match[2]; - const entity = typeof options.entity === 'function' - ? options.entity(match) - : options.entity || match[1] || 'User'; - - const record = createFactRecord({ - entity, - relation, - value, - validFrom: options.validFrom, - confidence, - category, - source: options.source, - rawText: sentence - }); - - if (record) { - target.push(record); - } - } -} - -function extractFactsFromSentence(sentence, options) { - const source = options.source || 'hook:event'; - const validFrom = options.validFrom || new Date().toISOString(); - const facts = []; - const subjectPattern = '([A-Za-z][a-z]+(?:\\s+[A-Za-z][a-z]+)?|i|we)'; - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?prefer(?:s|red|ring)?\\s+([^.;!?]+)`, 'gi'), - { relation: 'favorite_preference', category: 'preferences', confidence: 0.86, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?like(?:s|d)?\\s+([^.;!?]+)`, 'gi'), - { relation: 'favorite_preference', category: 'preferences', confidence: 0.8, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:really\\s+)?(?:hate|dislike(?:s|d)?)\\s+([^.;!?]+)`, 'gi'), - { relation: 'dislikes', category: 'preferences', confidence: 0.84, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:am|is|are)?\\s*allergic\\s+to\\s+([^.;!?]+)`, 'gi'), - { relation: 'allergic_to', category: 'preferences', confidence: 0.92, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:work|works|working)\\s+at\\s+([^.;!?]+)`, 'gi'), - { relation: 'works_at', category: 'facts', confidence: 0.92, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:live|lives|living)\\s+in\\s+([^.;!?]+)`, 'gi'), - { relation: 'lives_in', category: 'facts', confidence: 0.9, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:am|is|are)\\s+(\\d{1,3})\\s*(?:years?\\s*old)?\\b`, 'gi'), - { - relation: 'age', - category: 'facts', - confidence: 0.92, - source, - validFrom, - value: (match) => match[2] - } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+bought\\s+([^.;!?]+)`, 'gi'), - { relation: 'bought', category: 'facts', confidence: 0.86, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+spent\\s+\\$?(\\d+(?:\\.\\d{1,2})?)(?:\\s*(?:usd|dollars?))?(?:\\s+on\\s+([^.;!?]+))?`, 'gi'), - { - relation: 'spent', - category: 'facts', - confidence: 0.9, - source, - validFrom, - value: (match) => { - const amount = match[2] ? `$${match[2]}` : ''; - const onWhat = normalizeFactValue(match[3] || ''); - return onWhat ? `${amount} on ${onWhat}` : amount; - } - } - ); - - appendPatternFacts( - facts, - sentence, - new RegExp(`\\b${subjectPattern}\\s+(?:decided|chose)\\s+(?:to\\s+|on\\s+)?([^.;!?]+)`, 'gi'), - { relation: 'decided', category: 'decisions', confidence: 0.88, source, validFrom } - ); - - appendPatternFacts( - facts, - sentence, - /\bmy\s+partner\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi, - { relation: 'partner_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] } - ); - - appendPatternFacts( - facts, - sentence, - /\b([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\s+is\s+my\s+partner\b/gi, - { relation: 'partner_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] } - ); - - appendPatternFacts( - facts, - sentence, - /\bmy\s+dog\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi, - { relation: 'dog_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] } - ); - - appendPatternFacts( - facts, - sentence, - /\bmy\s+(?:mom|mother|dad|father|parent)\s+is\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)*)\b/gi, - { relation: 'parent_name', category: 'entities', confidence: 0.9, source, validFrom, entity: 'User', value: (match) => match[1] } - ); - - const deduped = []; - const seen = new Set(); - for (const fact of facts) { - const dedupeKey = `${fact.entityNorm}|${fact.relation}|${normalizeFactValue(fact.value).toLowerCase()}`; - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - deduped.push(fact); - } - - return deduped; -} - -function splitObservedTextIntoSentences(text) { - return sanitizeFactText(text, 6000) - .split(FACT_SENTENCE_SPLIT_RE) - .map((part) => sanitizeFactText(part)) - .filter((part) => part.length >= 8); -} - -function collectTextsFromMessageLike(target, value, depth = 0) { - if (depth > 3 || value === null || value === undefined) return; - - if (typeof value === 'string') { - const text = sanitizeFactText(value, 4000); - if (text) target.push(text); - return; - } - - if (Array.isArray(value)) { - for (const entry of value) { - collectTextsFromMessageLike(target, entry, depth + 1); - } - return; - } - - if (typeof value !== 'object') return; - - const direct = extractTextFromMessage(value); - if (direct) { - target.push(sanitizeFactText(direct, 4000)); - } - - const directKeys = ['text', 'message', 'content', 'rawText', 'observedText', 'observation', 'prompt']; - for (const key of directKeys) { - if (typeof value[key] === 'string') { - target.push(sanitizeFactText(value[key], 4000)); - } - } - - const nestedKeys = ['messages', 'history', 'entries', 'items', 'observations', 'events', 'payload', 'context']; - for (const key of nestedKeys) { - if (value[key] !== undefined) { - collectTextsFromMessageLike(target, value[key], depth + 1); - } - } -} - -function collectObservedTextsForFactExtraction(event) { - const collected = []; - - const directStringCandidates = [ - event?.text, - event?.message, - event?.content, - event?.rawText, - event?.context?.text, - event?.context?.message, - event?.context?.content, - event?.context?.rawText, - event?.context?.initialPrompt - ]; - - for (const candidate of directStringCandidates) { - if (typeof candidate === 'string') { - const text = sanitizeFactText(candidate, 4000); - if (text) collected.push(text); - } - } - - const structuredCandidates = [ - event?.messages, - event?.context?.messages, - event?.context?.history, - event?.context?.initialMessages, - event?.context?.memoryFlush, - event?.context?.flush, - event?.observations, - event?.context?.observations, - event?.payload?.messages, - event?.payload?.events - ]; - - for (const candidate of structuredCandidates) { - collectTextsFromMessageLike(collected, candidate); - } - - const deduped = []; - const seen = new Set(); - for (const item of collected) { - const normalized = sanitizeFactText(item, 4000); - if (!normalized) continue; - if (seen.has(normalized)) continue; - seen.add(normalized); - deduped.push(normalized); - } - return deduped; -} - -function extractFactsFromObservedText(observedTexts, options) { - const facts = []; - const globalSeen = new Set(); - for (const text of observedTexts) { - for (const sentence of splitObservedTextIntoSentences(text)) { - const extracted = extractFactsFromSentence(sentence, options); - for (const fact of extracted) { - const dedupeKey = `${fact.entityNorm}|${fact.relation}|${normalizeFactValue(fact.value).toLowerCase()}`; - if (globalSeen.has(dedupeKey)) continue; - globalSeen.add(dedupeKey); - facts.push(fact); - } - } - } - return facts; -} - -function normalizeStoredFact(raw) { - if (!raw || typeof raw !== 'object') return null; - const relation = normalizeFactRelation(raw.relation); - const value = normalizeFactValue(raw.value); - if (!relation || !value) return null; - - const entity = normalizeEntityLabel(raw.entity || raw.entityNorm || 'User'); - const entityNorm = normalizeEntityToken(raw.entityNorm || entity); - const validFrom = toIsoTimestamp(raw.validFrom || new Date().toISOString()); - let validUntil = null; - if (typeof raw.validUntil === 'string' && raw.validUntil.trim()) { - validUntil = toIsoTimestamp(raw.validUntil); - } - - const idBase = `${entityNorm}|${relation}|${value}|${validFrom}`; - const fallbackId = createHash('sha1').update(idBase).digest('hex').slice(0, 16); - - return { - id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : fallbackId, - entity, - entityNorm, - relation, - value, - validFrom, - validUntil, - confidence: clampConfidence(raw.confidence, 0.7), - category: sanitizeFactText(raw.category || 'facts', 40).toLowerCase() || 'facts', - source: sanitizeFactText(raw.source || 'hook', 120) || 'hook', - rawText: sanitizeFactText(raw.rawText || value, MAX_FACT_TEXT_LENGTH) - }; -} - -function readFactsFromVault(vaultPath) { - const factsPath = getFactsFilePath(vaultPath); - if (!fs.existsSync(factsPath)) { - return []; - } - - try { - const lines = fs.readFileSync(factsPath, 'utf-8') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const facts = []; - for (const line of lines) { - try { - const parsed = JSON.parse(line); - const normalized = normalizeStoredFact(parsed); - if (normalized) facts.push(normalized); - } catch { - // Skip malformed lines and keep processing. - } - } - return facts; - } catch { - return []; - } -} - -function writeFactsToVault(vaultPath, facts) { - const factsPath = getFactsFilePath(vaultPath); - const lines = facts.map((fact) => JSON.stringify(fact)); - const payload = lines.length > 0 ? `${lines.join('\n')}\n` : ''; - fs.writeFileSync(factsPath, payload, 'utf-8'); -} - -function mergeFactsWithConflictResolution(existingFacts, incomingFacts) { - const merged = [...existingFacts]; - let added = 0; - let superseded = 0; - let changed = false; - - for (const incoming of incomingFacts) { - const activeSameRelation = merged.filter((fact) => - fact.entityNorm === incoming.entityNorm - && fact.relation === incoming.relation - && !fact.validUntil - ); - - const incomingValue = normalizeFactValue(incoming.value).toLowerCase(); - const hasExactActiveMatch = activeSameRelation.some((fact) => - normalizeFactValue(fact.value).toLowerCase() === incomingValue - ); - if (hasExactActiveMatch) { - continue; - } - - const shouldSupersede = activeSameRelation.some((fact) => - normalizeFactValue(fact.value).toLowerCase() !== incomingValue - ); - if (shouldSupersede || isExclusiveFactRelation(incoming.relation)) { - for (const fact of activeSameRelation) { - if (normalizeFactValue(fact.value).toLowerCase() === incomingValue) continue; - if (!fact.validUntil) { - fact.validUntil = incoming.validFrom; - superseded += 1; - changed = true; - } - } - } - - merged.push(incoming); - added += 1; - changed = true; - } - - return { facts: merged, added, superseded, changed }; -} - -function isTimestampAfter(candidate, reference) { - const candidateTime = new Date(candidate).getTime(); - const referenceTime = new Date(reference).getTime(); - if (Number.isNaN(candidateTime)) return false; - if (Number.isNaN(referenceTime)) return true; - return candidateTime > referenceTime; -} - -function ensureGraphNode(nodesById, descriptor, seenAt) { - const existing = nodesById.get(descriptor.id); - if (!existing) { - nodesById.set(descriptor.id, { - id: descriptor.id, - name: descriptor.name, - displayName: descriptor.displayName, - type: descriptor.type, - attributes: descriptor.attributes || {}, - lastSeen: seenAt - }); - return; - } - - existing.attributes = { ...existing.attributes, ...(descriptor.attributes || {}) }; - if (isTimestampAfter(seenAt, existing.lastSeen)) { - existing.lastSeen = seenAt; - } -} - -function inferTargetNodeType(relation) { - if (relation === 'works_at') return 'organization'; - if (relation === 'lives_in') return 'location'; - if (relation === 'partner_name' || relation === 'parent_name') return 'person'; - if (relation === 'dog_name') return 'pet'; - if (relation === 'age' || relation === 'spent') return 'number'; - if (relation === 'bought') return 'item'; - if (relation === 'decided') return 'decision'; - if (relation === 'allergic_to') return 'substance'; - if (relation === 'favorite_preference' || relation === 'dislikes') return 'preference'; - return 'attribute'; -} - -function buildTargetNodeDescriptor(fact) { - const relation = normalizeFactRelation(fact.relation); - const value = normalizeFactValue(fact.value); - if (!relation || !value) return null; - - if (ENTITY_TARGET_RELATIONS.has(relation)) { - const normalizedEntityValue = normalizeEntityToken(value); - return { - id: `entity:${slugifyForId(normalizedEntityValue)}`, - name: normalizedEntityValue, - displayName: value, - type: inferTargetNodeType(relation), - attributes: { relation } - }; - } - - return { - id: `value:${relation}:${slugifyForId(value)}`, - name: value.toLowerCase(), - displayName: value, - type: inferTargetNodeType(relation), - attributes: { relation } - }; -} - -function buildEntityGraphFromFacts(facts) { - const nodesById = new Map(); - const edges = []; - - for (const fact of facts) { - const normalized = normalizeStoredFact(fact); - if (!normalized) continue; - - const sourceNodeId = `entity:${slugifyForId(normalized.entityNorm)}`; - const seenAt = normalized.validFrom || new Date().toISOString(); - ensureGraphNode(nodesById, { - id: sourceNodeId, - name: normalized.entityNorm, - displayName: normalized.entity, - type: 'person', - attributes: { entityNorm: normalized.entityNorm } - }, seenAt); - - const targetNode = buildTargetNodeDescriptor(normalized); - if (!targetNode) continue; - ensureGraphNode(nodesById, targetNode, seenAt); - - const edgeHashSource = `${normalized.id}|${sourceNodeId}|${targetNode.id}|${normalized.relation}|${normalized.validFrom}`; - const edgeId = `edge:${createHash('sha1').update(edgeHashSource).digest('hex').slice(0, 18)}`; - - edges.push({ - id: edgeId, - source: sourceNodeId, - target: targetNode.id, - relation: normalized.relation, - validFrom: normalized.validFrom, - validUntil: normalized.validUntil, - confidence: clampConfidence(normalized.confidence, 0.7) - }); - } - - const nodes = [...nodesById.values()].sort((a, b) => a.id.localeCompare(b.id)); - const sortedEdges = edges.sort((a, b) => a.id.localeCompare(b.id)); - return { - version: ENTITY_GRAPH_VERSION, - nodes, - edges: sortedEdges - }; -} - -function writeEntityGraphToVault(vaultPath, facts) { - const graphPath = getEntityGraphFilePath(vaultPath); - const graph = buildEntityGraphFromFacts(facts); - fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), 'utf-8'); -} - -function persistExtractedFacts(vaultPath, incomingFacts) { - const existingFacts = readFactsFromVault(vaultPath); - const normalizedIncomingFacts = incomingFacts - .map((fact) => normalizeStoredFact(fact)) - .filter(Boolean); - - if (normalizedIncomingFacts.length === 0) { - writeEntityGraphToVault(vaultPath, existingFacts); - return { facts: existingFacts, added: 0, superseded: 0 }; - } - - const { facts, added, superseded, changed } = mergeFactsWithConflictResolution( - existingFacts, - normalizedIncomingFacts - ); - - if (changed || !fs.existsSync(getFactsFilePath(vaultPath))) { - writeFactsToVault(vaultPath, facts); - } - writeEntityGraphToVault(vaultPath, facts); - return { facts, added, superseded }; -} - -function runFactExtractionForEvent(vaultPath, event, eventLabel) { - try { - const observedTexts = collectObservedTextsForFactExtraction(event); - if (observedTexts.length === 0) { - console.log(`[clawvault] Fact extraction skipped (${eventLabel}: no observed text)`); - return; - } - - const validFrom = toIsoTimestamp(extractEventTimestamp(event) || new Date()); - const source = `hook:${eventLabel}`; - const extracted = extractFactsFromObservedText(observedTexts, { source, validFrom }); - - if (extracted.length === 0) { - console.log(`[clawvault] Fact extraction found no matches (${eventLabel})`); - return; - } - - const { facts, added, superseded } = persistExtractedFacts(vaultPath, extracted); - console.log(`[clawvault] Fact extraction complete (${eventLabel}): +${added}, superseded ${superseded}, total ${facts.length}`); - } catch (err) { - console.warn(`[clawvault] Fact extraction failed (${eventLabel}): ${err?.message || 'unknown error'}`); - } -} - -function extractEventTimestamp(event) { - const candidates = [ - event?.timestamp, - event?.scheduledAt, - event?.time, - event?.context?.timestamp, - event?.context?.scheduledAt - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const parsed = new Date(candidate); - if (!Number.isNaN(parsed.getTime())) { - return parsed; - } - } - return null; -} - -function isSundayMidnightUtc(date) { - return date.getUTCDay() === 0 && date.getUTCHours() === 0 && date.getUTCMinutes() === 0; -} - -async function handleWeeklyReflect(event) { - const pluginConfig = extractPluginConfig(event); - if (!isOptInEnabled(pluginConfig, 'enableWeeklyReflection', 'weeklyReflection')) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping weekly reflection'); - return; - } - - const timestamp = extractEventTimestamp(event) || new Date(); - if (!isSundayMidnightUtc(timestamp)) { - console.log('[clawvault] Weekly reflect skipped (not Sunday midnight UTC)'); - return; - } - - const result = runClawvault(['reflect', '-v', vaultPath], pluginConfig, { timeoutMs: 120000 }); - if (result.skipped) { - console.log('[clawvault] Weekly reflection skipped: allowClawvaultExec is disabled'); - return; - } - if (!result.success) { - console.warn('[clawvault] Weekly reflection failed'); - return; - } - console.log('[clawvault] Weekly reflection complete'); -} - -// Handle gateway startup - check for context death -async function handleStartup(event) { - const pluginConfig = extractPluginConfig(event); - if (!isOptInEnabled(pluginConfig, 'enableStartupRecovery')) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping recovery check'); - return; - } - - console.log(`[clawvault] Checking for context death`); - - // Pass vault path as separate argument (not interpolated) - const result = runClawvault(['recover', '--clear', '-v', vaultPath], pluginConfig); - if (result.skipped) { - console.log('[clawvault] Recovery check skipped: allowClawvaultExec is disabled'); - return; - } - - if (!result.success) { - console.warn('[clawvault] Recovery check failed'); - return; - } - - const { hadDeath, workingOn } = parseRecoveryOutput(result.output); - - if (hadDeath) { - // Build safe alert message with sanitized content - const alertParts = ['[ClawVault] Context death detected.']; - if (workingOn) { - alertParts.push(`Last working on: ${workingOn}`); - } - alertParts.push('Run `clawvault wake` for full recovery context.'); - - const alertMsg = alertParts.join(' '); - - // Inject into event messages if available - if (injectSystemMessage(event, alertMsg)) { - console.warn('[clawvault] Context death detected, alert injected'); - } - } else { - console.log('[clawvault] Clean startup - no context death'); - } -} - -// Handle /new command - auto-checkpoint before reset -async function handleNew(event) { - const pluginConfig = extractPluginConfig(event); - const autoCheckpointEnabled = isOptInEnabled(pluginConfig, 'enableAutoCheckpoint', 'autoCheckpoint'); - const observerOnNewEnabled = isOptInEnabled(pluginConfig, 'enableObserveOnNew'); - const factExtractionEnabled = isOptInEnabled(pluginConfig, 'enableFactExtraction'); - if (!autoCheckpointEnabled && !observerOnNewEnabled && !factExtractionEnabled) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping auto-checkpoint'); - return; - } - - // Sanitize session info for checkpoint - const sessionKey = typeof event.sessionKey === 'string' - ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100) - : 'unknown'; - const source = typeof event.context?.commandSource === 'string' - ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50) - : 'cli'; - - if (autoCheckpointEnabled) { - console.log('[clawvault] Auto-checkpoint before /new'); - const result = runClawvault([ - 'checkpoint', - '--working-on', `Session reset via /new from ${source}`, - '--focus', `Pre-reset checkpoint, session: ${sessionKey}`, - '-v', vaultPath - ], pluginConfig); - - if (result.skipped) { - console.log('[clawvault] Auto-checkpoint skipped: allowClawvaultExec is disabled'); - } else if (result.success) { - console.log('[clawvault] Auto-checkpoint created'); - } else { - console.warn('[clawvault] Auto-checkpoint failed'); - } - } - - const agentId = resolveAgentIdForEvent(event, pluginConfig); - if (observerOnNewEnabled) { - runObserverCron(vaultPath, agentId, pluginConfig, { - minNewBytes: 1, - reason: 'command:new flush' - }); - } - if (factExtractionEnabled) { - runFactExtractionForEvent(vaultPath, event, 'command:new'); - } -} - -// Handle session start - inject dynamic context for first prompt -async function handleSessionStart(event) { - const pluginConfig = extractPluginConfig(event); - if (!isOptInEnabled(pluginConfig, 'enableSessionContextInjection')) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping context injection'); - return; - } - - const sessionKey = extractSessionKey(event); - const prompt = extractInitialPrompt(event); - let recapEntries = []; - let memoryEntries = []; - - if (sessionKey) { - console.log('[clawvault] Fetching session recap for context restoration'); - const recapArgs = ['session-recap', sessionKey, '--format', 'json']; - const agentId = extractAgentIdFromSessionKey(sessionKey); - if (agentId) { - recapArgs.push('--agent', agentId); - } - - const recapResult = runClawvault(recapArgs, pluginConfig); - if (recapResult.skipped) { - console.log('[clawvault] Session recap skipped: allowClawvaultExec is disabled'); - } - if (recapResult.success) { - recapEntries = parseSessionRecapJson(recapResult.output); - } else if (!recapResult.skipped) { - console.warn('[clawvault] Session recap lookup failed'); - } - } else { - console.log('[clawvault] No session key found, skipping session recap'); - } - - if (prompt) { - console.log('[clawvault] Fetching vault memories for session start prompt'); - const contextResult = runClawvault([ - 'context', - prompt, - '--format', 'json', - '--profile', 'auto', - '-v', vaultPath - ], pluginConfig); - - if (contextResult.success) { - memoryEntries = parseContextJson(contextResult.output); - } else if (contextResult.skipped) { - console.log('[clawvault] Context lookup skipped: allowClawvaultExec is disabled'); - } else { - console.warn('[clawvault] Context lookup failed'); - } - } else { - console.log('[clawvault] No initial prompt, skipping vault memory lookup'); - } - - if (recapEntries.length === 0 && memoryEntries.length === 0) { - console.log('[clawvault] No session context available to inject'); - return; - } - - if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) { - console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`); - } else { - console.log('[clawvault] No message array available, skipping injection'); - } -} - -// Handle heartbeat events - cheap stat-based trigger for active observation -async function handleHeartbeat(event) { - const pluginConfig = extractPluginConfig(event); - if (!isOptInEnabled(pluginConfig, 'enableHeartbeatObservation', 'observeOnHeartbeat')) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping heartbeat observation check'); - return; - } - - const agentId = resolveAgentIdForEvent(event, pluginConfig); - if (!shouldObserveActiveSessions(vaultPath, agentId, pluginConfig)) { - console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold'); - return; - } - - runObserverCron(vaultPath, agentId, pluginConfig, { reason: 'heartbeat threshold crossed' }); -} - -// Handle context compaction - force flush any pending session deltas -async function handleContextCompaction(event) { - const pluginConfig = extractPluginConfig(event); - const compactionObserveEnabled = isOptInEnabled(pluginConfig, 'enableCompactionObservation'); - const factExtractionEnabled = isOptInEnabled(pluginConfig, 'enableFactExtraction'); - if (!compactionObserveEnabled && !factExtractionEnabled) { - return; - } - - const vaultPath = findVaultPath(event, pluginConfig); - if (!vaultPath) { - console.log('[clawvault] No vault found, skipping compaction observation'); - return; - } - - const agentId = resolveAgentIdForEvent(event, pluginConfig); - if (compactionObserveEnabled) { - runObserverCron(vaultPath, agentId, pluginConfig, { - minNewBytes: 1, - reason: 'context compaction' - }); - } - if (factExtractionEnabled) { - runFactExtractionForEvent(vaultPath, event, 'compaction:memoryFlush'); - } -} - -// Main handler - route events -const handler = async (event) => { - try { - if (eventMatches(event, 'gateway', 'startup')) { - await handleStartup(event); - return; - } - - if ( - eventMatches(event, 'cron', 'weekly') - || eventIncludesToken(event, 'cron:weekly') - ) { - await handleWeeklyReflect(event); - return; - } - - if ( - eventMatches(event, 'gateway', 'heartbeat') - || eventMatches(event, 'session', 'heartbeat') - || eventIncludesToken(event, 'heartbeat') - ) { - await handleHeartbeat(event); - return; - } - - if ( - eventMatches(event, 'compaction', 'memoryflush') - || eventMatches(event, 'context', 'compaction') - || eventMatches(event, 'context', 'compact') - || eventIncludesToken(event, 'compaction') - || eventIncludesToken(event, 'memoryflush') - ) { - await handleContextCompaction(event); - return; - } - - if (eventMatches(event, 'command', 'new')) { - await handleNew(event); - return; - } - - if (eventMatches(event, 'session', 'start')) { - await handleSessionStart(event); - return; - } - } catch (err) { - console.error('[clawvault] Hook error:', err.message || 'unknown error'); - } -}; - -export default handler; diff --git a/hooks/clawvault/handler.test.js b/hooks/clawvault/handler.test.js deleted file mode 100644 index 3d9862ef..00000000 --- a/hooks/clawvault/handler.test.js +++ /dev/null @@ -1,576 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -const { execFileSyncMock, resolveExecutablePathMock, verifyExecutableIntegrityMock, sanitizeExecArgsMock } = vi.hoisted(() => ({ - execFileSyncMock: vi.fn(), - resolveExecutablePathMock: vi.fn(), - verifyExecutableIntegrityMock: vi.fn(), - sanitizeExecArgsMock: vi.fn() -})); - -vi.mock('child_process', () => ({ - execFileSync: execFileSyncMock -})); - -vi.mock('./integrity.js', () => ({ - resolveExecutablePath: resolveExecutablePathMock, - verifyExecutableIntegrity: verifyExecutableIntegrityMock, - sanitizeExecArgs: sanitizeExecArgsMock -})); - -function makeVaultFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-hook-')); - fs.writeFileSync(path.join(root, '.clawvault.json'), JSON.stringify({ name: 'test' }), 'utf-8'); - return root; -} - -function makeOpenClawSessionFixture(agentId, sessionId, transcriptBytes = 0) { - const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-openclaw-')); - const sessionsDir = path.join(stateRoot, 'agents', agentId, 'sessions'); - fs.mkdirSync(sessionsDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionsDir, 'sessions.json'), - JSON.stringify({ - [`agent:${agentId}:main`]: { - sessionId, - updatedAt: Date.now() - } - }), - 'utf-8' - ); - const transcriptPath = path.join(sessionsDir, `${sessionId}.jsonl`); - const payload = transcriptBytes > 0 ? 'x'.repeat(transcriptBytes) : ''; - fs.writeFileSync(transcriptPath, payload, 'utf-8'); - return { stateRoot, sessionsDir, transcriptPath }; -} - -async function loadHandler() { - vi.resetModules(); - const mod = await import('./handler.js'); - return mod.default; -} - -afterEach(() => { - vi.clearAllMocks(); - resolveExecutablePathMock.mockReset(); - verifyExecutableIntegrityMock.mockReset(); - sanitizeExecArgsMock.mockReset(); - delete process.env.CLAWVAULT_PATH; - delete process.env.OPENCLAW_STATE_DIR; - delete process.env.OPENCLAW_HOME; - delete process.env.OPENCLAW_AGENT_ID; - delete process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH; -}); - -function securePluginConfig(vaultPath, overrides = {}) { - return { - vaultPath, - allowClawvaultExec: true, - enableStartupRecovery: true, - enableSessionContextInjection: true, - enableAutoCheckpoint: true, - enableObserveOnNew: true, - enableHeartbeatObservation: true, - enableCompactionObservation: true, - enableWeeklyReflection: true, - enableFactExtraction: true, - ...overrides - }; -} - -function setupIntegrityDefaults() { - resolveExecutablePathMock.mockReturnValue('/usr/local/bin/clawvault'); - verifyExecutableIntegrityMock.mockReturnValue({ ok: true, actualSha256: 'a'.repeat(64) }); - sanitizeExecArgsMock.mockImplementation((args) => args); -} - -describe('clawvault hook handler', () => { - beforeEach(() => { - setupIntegrityDefaults(); - }); - - it('injects recovery warning on gateway startup when death detected', async () => { - const vaultPath = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return '⚠️ CONTEXT DEATH DETECTED\nWorking on: ship memory graph'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(vaultPath), - messages: [{ role: 'user', content: 'hello' }] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', vaultPath]), - expect.objectContaining({ shell: false }) - ); - const injected = event.messages.find((message) => message.role === 'system'); - expect(injected?.content).toContain('Context death detected'); - expect(injected?.content).toContain('ship memory graph'); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('does not execute clawvault commands unless allowClawvaultExec is true', async () => { - const vaultPath = makeVaultFixture(); - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(vaultPath, { allowClawvaultExec: false }), - messages: [] - }; - - await handler(event); - expect(execFileSyncMock).not.toHaveBeenCalled(); - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('fails closed when configured executable hash does not match', async () => { - const vaultPath = makeVaultFixture(); - verifyExecutableIntegrityMock.mockReturnValue({ ok: false, actualSha256: 'b'.repeat(64) }); - const handler = await loadHandler(); - await handler({ - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(vaultPath, { - clawvaultBinarySha256: 'a'.repeat(64) - }), - messages: [] - }); - - expect(execFileSyncMock).not.toHaveBeenCalled(); - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('supports alias event names for command:new', async () => { - const vaultPath = makeVaultFixture(); - execFileSyncMock.mockReturnValue(''); - - const handler = await loadHandler(); - await handler({ - event: 'command:new', - sessionKey: 'agent:clawdious:main', - pluginConfig: securePluginConfig(vaultPath), - context: { commandSource: 'cli' } - }); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['checkpoint', '--working-on']), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('injects recap and memory context on session start alias event', async () => { - const vaultPath = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'session-recap') { - return JSON.stringify({ - messages: [ - { role: 'user', text: 'Need a migration plan.' }, - { role: 'assistant', text: 'Suggested phased rollout.' } - ] - }); - } - if (args[0] === 'context') { - return JSON.stringify({ - context: [ - { - title: 'Use Postgres', - age: '1 day ago', - snippet: 'Selected Postgres for durability.' - } - ] - }); - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - eventName: 'session:start', - sessionKey: 'agent:clawdious:main', - pluginConfig: securePluginConfig(vaultPath), - context: { initialPrompt: 'Need migration plan' }, - messages: [{ role: 'user', content: 'Need migration plan' }] - }; - - await handler(event); - - const contextCall = execFileSyncMock.mock.calls.find((call) => call[1]?.[0] === 'context'); - expect(contextCall?.[1]).toEqual(expect.arrayContaining(['--profile', 'auto'])); - - const injected = event.messages.find((message) => message.role === 'system'); - expect(injected?.content).toContain('Session context restored'); - expect(injected?.content).toContain('Recent conversation'); - expect(injected?.content).toContain('Relevant memories'); - expect(injected?.content).toContain('Use Postgres'); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('delegates profile selection to context auto mode for urgent prompts', async () => { - const vaultPath = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'session-recap') { - return JSON.stringify({ messages: [] }); - } - if (args[0] === 'context') { - return JSON.stringify({ context: [] }); - } - return ''; - }); - - const handler = await loadHandler(); - await handler({ - eventName: 'session:start', - sessionKey: 'agent:clawdious:main', - pluginConfig: securePluginConfig(vaultPath), - context: { initialPrompt: 'URGENT outage: rollback failed in production' }, - messages: [{ role: 'user', content: 'URGENT outage: rollback failed in production' }] - }); - - const contextCall = execFileSyncMock.mock.calls.find((call) => call[1]?.[0] === 'context'); - expect(contextCall?.[1]).toEqual(expect.arrayContaining(['--profile', 'auto'])); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('triggers active observation on heartbeat when threshold is crossed', async () => { - const vaultPath = makeVaultFixture(); - const sessionId = 'heartbeat-session-1'; - const openClawFixture = makeOpenClawSessionFixture('main', sessionId, 70 * 1024); - process.env.OPENCLAW_STATE_DIR = openClawFixture.stateRoot; - - fs.mkdirSync(path.join(vaultPath, '.clawvault'), { recursive: true }); - fs.writeFileSync( - path.join(vaultPath, '.clawvault', 'observe-cursors.json'), - JSON.stringify({ - [sessionId]: { - lastObservedOffset: 0, - lastObservedAt: '2026-02-14T00:00:00.000Z', - sessionKey: 'agent:main:main', - lastFileSize: 0 - } - }), - 'utf-8' - ); - - execFileSyncMock.mockReturnValue(''); - - const handler = await loadHandler(); - await handler({ - type: 'gateway', - action: 'heartbeat', - pluginConfig: securePluginConfig(vaultPath, { allowEnvAccess: true }) - }); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['observe', '--cron', '--agent', 'main']), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - fs.rmSync(openClawFixture.stateRoot, { recursive: true, force: true }); - }); - - it('forces active observation flush on compaction events', async () => { - const vaultPath = makeVaultFixture(); - execFileSyncMock.mockReturnValue(''); - - const handler = await loadHandler(); - await handler({ - eventName: 'compaction:memoryFlush', - sessionKey: 'agent:clawdious:main', - pluginConfig: securePluginConfig(vaultPath) - }); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['observe', '--cron', '--min-new', '1']), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('runs weekly reflection on cron.weekly at Sunday midnight', async () => { - const vaultPath = makeVaultFixture(); - execFileSyncMock.mockReturnValue(''); - - const handler = await loadHandler(); - await handler({ - eventName: 'cron.weekly', - timestamp: '2026-02-15T00:00:00.000Z', - pluginConfig: securePluginConfig(vaultPath) - }); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['reflect', '-v', vaultPath]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('uses vaultPath from plugin config when provided in event', async () => { - const vaultPath = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(vaultPath), - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', vaultPath]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('uses vaultPath from context.pluginConfig when provided', async () => { - const vaultPath = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - context: { - pluginConfig: securePluginConfig(vaultPath) - }, - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', vaultPath]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('uses vaultPath from OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH env var', async () => { - const vaultPath = makeVaultFixture(); - process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH = vaultPath; - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(vaultPath, { allowEnvAccess: true }), - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', vaultPath]), - expect.objectContaining({ shell: false }) - ); - - delete process.env.OPENCLAW_PLUGIN_CLAWVAULT_VAULTPATH; - fs.rmSync(vaultPath, { recursive: true, force: true }); - }); - - it('uses per-agent vault path from agentVaults config', async () => { - const agent1Vault = makeVaultFixture(); - const agent2Vault = makeVaultFixture(); - const fallbackVault = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - sessionKey: 'agent:agent1:main', - pluginConfig: securePluginConfig(fallbackVault, { - agentVaults: { - agent1: agent1Vault, - agent2: agent2Vault - } - }), - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', agent1Vault]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(agent1Vault, { recursive: true, force: true }); - fs.rmSync(agent2Vault, { recursive: true, force: true }); - fs.rmSync(fallbackVault, { recursive: true, force: true }); - }); - - it('falls back to vaultPath when agent not in agentVaults', async () => { - const agent1Vault = makeVaultFixture(); - const fallbackVault = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - sessionKey: 'agent:unknown-agent:main', - pluginConfig: securePluginConfig(fallbackVault, { - agentVaults: { - agent1: agent1Vault - } - }), - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', fallbackVault]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(agent1Vault, { recursive: true, force: true }); - fs.rmSync(fallbackVault, { recursive: true, force: true }); - }); - - it('uses agentVaults from context.pluginConfig', async () => { - const agent1Vault = makeVaultFixture(); - const fallbackVault = makeVaultFixture(); - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - sessionKey: 'agent:agent1:main', - context: { - pluginConfig: securePluginConfig(fallbackVault, { - agentVaults: { - agent1: agent1Vault - } - }) - }, - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', agent1Vault]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(agent1Vault, { recursive: true, force: true }); - fs.rmSync(fallbackVault, { recursive: true, force: true }); - }); - - it('uses OPENCLAW_AGENT_ID env var for agent resolution when session key not available', async () => { - const agent1Vault = makeVaultFixture(); - const fallbackVault = makeVaultFixture(); - process.env.OPENCLAW_AGENT_ID = 'agent1'; - - execFileSyncMock.mockImplementation((_command, args) => { - if (args[0] === 'recover') { - return 'Clean startup'; - } - return ''; - }); - - const handler = await loadHandler(); - const event = { - type: 'gateway', - action: 'startup', - pluginConfig: securePluginConfig(fallbackVault, { - vaultPath: fallbackVault, - agentVaults: { - agent1: agent1Vault - }, - allowEnvAccess: true - }), - messages: [] - }; - - await handler(event); - - expect(execFileSyncMock).toHaveBeenCalledWith( - '/usr/local/bin/clawvault', - expect.arrayContaining(['recover', '--clear', '-v', agent1Vault]), - expect.objectContaining({ shell: false }) - ); - - fs.rmSync(agent1Vault, { recursive: true, force: true }); - fs.rmSync(fallbackVault, { recursive: true, force: true }); - }); -}); diff --git a/hooks/clawvault/integrity.js b/hooks/clawvault/integrity.js deleted file mode 100644 index a59040fb..00000000 --- a/hooks/clawvault/integrity.js +++ /dev/null @@ -1,112 +0,0 @@ -import { createHash } from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; - -const WINDOWS_PATHEXT_DEFAULT = '.EXE;.CMD;.BAT;.COM'; - -function splitPathEnv(pathEnv) { - if (typeof pathEnv !== 'string' || !pathEnv.trim()) { - return []; - } - return pathEnv - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function listExecutableCandidates(commandName) { - if (process.platform !== 'win32') { - return [commandName]; - } - - const ext = path.extname(commandName); - if (ext) { - return [commandName]; - } - - const pathext = splitPathEnv(process.env.PATHEXT || WINDOWS_PATHEXT_DEFAULT) - .map((candidate) => candidate.toLowerCase()); - if (pathext.length === 0) { - return [commandName]; - } - return pathext.map((candidate) => `${commandName}${candidate}`); -} - -function isExecutablePath(candidatePath) { - try { - const stats = fs.statSync(candidatePath); - if (!stats.isFile()) return false; - if (process.platform === 'win32') return true; - return (stats.mode & 0o111) !== 0; - } catch { - return false; - } -} - -function resolveFromPath(commandName, pathEnv) { - const candidates = listExecutableCandidates(commandName); - const directories = splitPathEnv(pathEnv); - for (const directory of directories) { - for (const candidate of candidates) { - const absoluteCandidate = path.resolve(directory, candidate); - if (isExecutablePath(absoluteCandidate)) { - return absoluteCandidate; - } - } - } - return null; -} - -export function resolveExecutablePath(commandName, options = {}) { - if (typeof commandName !== 'string' || !commandName.trim()) { - return null; - } - - const explicitPath = typeof options.explicitPath === 'string' - ? options.explicitPath.trim() - : ''; - if (explicitPath) { - const absolute = path.resolve(explicitPath); - return isExecutablePath(absolute) ? absolute : null; - } - - if (commandName.includes(path.sep)) { - const absolute = path.resolve(commandName); - return isExecutablePath(absolute) ? absolute : null; - } - - return resolveFromPath(commandName, process.env.PATH || ''); -} - -export function sanitizeExecArgs(args) { - if (!Array.isArray(args)) { - throw new Error('Arguments must be an array'); - } - return args.map((value, index) => { - if (typeof value !== 'string') { - throw new Error(`Argument ${index} is not a string`); - } - if (value.includes('\0')) { - throw new Error(`Argument ${index} contains a null byte`); - } - return value; - }); -} - -export function verifyExecutableIntegrity(executablePath, expectedSha256) { - if (typeof expectedSha256 !== 'string' || !expectedSha256.trim()) { - return { ok: true, actualSha256: null }; - } - - const normalizedExpected = expectedSha256.trim().toLowerCase(); - if (!/^[a-f0-9]{64}$/.test(normalizedExpected)) { - return { ok: false, actualSha256: null }; - } - - const payload = fs.readFileSync(executablePath); - const actualSha256 = createHash('sha256').update(payload).digest('hex').toLowerCase(); - return { - ok: actualSha256 === normalizedExpected, - actualSha256 - }; -} diff --git a/hooks/clawvault/integrity.test.js b/hooks/clawvault/integrity.test.js deleted file mode 100644 index 0256bb13..00000000 --- a/hooks/clawvault/integrity.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createHash } from 'crypto'; -import * as fs from 'fs'; -import { - resolveExecutablePath, - sanitizeExecArgs, - verifyExecutableIntegrity -} from './integrity.js'; - -describe('hook executable integrity helpers', () => { - it('resolves an explicit executable path', () => { - const resolved = resolveExecutablePath('clawvault', { explicitPath: process.execPath }); - expect(resolved).toBe(process.execPath); - }); - - it('rejects non-array arguments', () => { - expect(() => sanitizeExecArgs('not-an-array')).toThrow('Arguments must be an array'); - }); - - it('rejects null-byte arguments', () => { - expect(() => sanitizeExecArgs(['ok', 'bad\0arg'])).toThrow('contains a null byte'); - }); - - it('verifies expected executable sha256', () => { - const expected = createHash('sha256') - .update(fs.readFileSync(process.execPath)) - .digest('hex'); - const result = verifyExecutableIntegrity(process.execPath, expected); - expect(result.ok).toBe(true); - expect(result.actualSha256).toBe(expected); - }); -}); diff --git a/hooks/clawvault/openclaw.plugin.json b/hooks/clawvault/openclaw.plugin.json deleted file mode 100644 index b69861d8..00000000 --- a/hooks/clawvault/openclaw.plugin.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "id": "clawvault", - "name": "ClawVault", - "version": "2.7.0", - "description": "Structured memory system for AI agents with context death resilience", - "kind": "memory", - "configSchema": { - "type": "object", - "properties": { - "vaultPath": { - "type": "string", - "description": "Path to the ClawVault vault directory. Used as fallback when agentVaults is not set or agent not found." - }, - "agentVaults": { - "type": "object", - "description": "Mapping of agent names to vault paths. Allows each agent to have its own vault. Falls back to vaultPath if agent not found.", - "additionalProperties": { - "type": "string", - "description": "Path to the vault directory for this agent" - } - }, - "allowClawvaultExec": { - "type": "boolean", - "description": "Security gate for all child_process execution from the plugin. Must be true to run clawvault CLI commands.", - "default": false - }, - "clawvaultBinaryPath": { - "type": "string", - "description": "Optional absolute path to the clawvault executable. When omitted, the plugin resolves clawvault from PATH." - }, - "clawvaultBinarySha256": { - "type": "string", - "description": "Optional SHA-256 checksum for clawvaultBinaryPath (or resolved clawvault executable) to enforce binary integrity verification." - }, - "allowEnvAccess": { - "type": "boolean", - "description": "Allow reading OPENCLAW_* and CLAWVAULT_PATH environment variables for vault and agent discovery.", - "default": false - }, - "enableStartupRecovery": { - "type": "boolean", - "description": "Enable recovery checks on gateway startup.", - "default": false - }, - "enableSessionContextInjection": { - "type": "boolean", - "description": "Enable recap/context injection on session start.", - "default": false - }, - "enableAutoCheckpoint": { - "type": "boolean", - "description": "Enable automatic checkpointing on /new.", - "default": false - }, - "enableObserveOnNew": { - "type": "boolean", - "description": "Enable observer flush on /new.", - "default": false - }, - "enableHeartbeatObservation": { - "type": "boolean", - "description": "Enable observation threshold checks on heartbeat.", - "default": false - }, - "enableCompactionObservation": { - "type": "boolean", - "description": "Enable forced observer flush on compaction events.", - "default": false - }, - "enableWeeklyReflection": { - "type": "boolean", - "description": "Enable weekly reflection on Sunday midnight UTC.", - "default": false - }, - "enableFactExtraction": { - "type": "boolean", - "description": "Enable extraction of structured facts from observed text during hook events.", - "default": false - }, - "autoCheckpoint": { - "type": "boolean", - "description": "Deprecated alias for enableAutoCheckpoint.", - "default": false - }, - "contextProfile": { - "type": "string", - "enum": ["default", "planning", "incident", "handoff", "auto"], - "description": "Default context profile for session start injection", - "default": "auto" - }, - "maxContextResults": { - "type": "integer", - "minimum": 1, - "maximum": 20, - "description": "Maximum number of context results to inject on session start", - "default": 4 - }, - "observeOnHeartbeat": { - "type": "boolean", - "description": "Deprecated alias for enableHeartbeatObservation.", - "default": false - }, - "weeklyReflection": { - "type": "boolean", - "description": "Deprecated alias for enableWeeklyReflection.", - "default": false - } - }, - "additionalProperties": false - }, - "uiHints": { - "vaultPath": { - "label": "Vault Path", - "placeholder": "~/my-vault", - "description": "Path to your ClawVault memory vault (fallback when agentVaults not set)" - }, - "agentVaults": { - "label": "Agent Vaults", - "description": "Per-agent vault paths (e.g., {\"agent1\": \"/path/to/vault1\", \"agent2\": \"/path/to/vault2\"})" - }, - "allowClawvaultExec": { - "label": "Allow CLI Execution", - "description": "Required opt-in to run child_process calls from the plugin." - }, - "clawvaultBinaryPath": { - "label": "ClawVault Binary Path", - "description": "Absolute path to clawvault executable for stricter execution controls." - }, - "clawvaultBinarySha256": { - "label": "ClawVault Binary SHA-256", - "description": "Optional checksum to verify executable integrity before execution." - }, - "allowEnvAccess": { - "label": "Allow Environment Access", - "description": "Allow OPENCLAW_* and CLAWVAULT_PATH reads for fallback discovery." - }, - "enableStartupRecovery": { - "label": "Startup Recovery", - "description": "Run recovery checks on gateway startup." - }, - "enableSessionContextInjection": { - "label": "Session Context Injection", - "description": "Inject recap and memory context on session start." - }, - "enableAutoCheckpoint": { - "label": "Auto Checkpoint", - "description": "Automatically checkpoint before /new." - }, - "enableObserveOnNew": { - "label": "Observe On /new", - "description": "Run observer flush on /new." - }, - "enableHeartbeatObservation": { - "label": "Observe On Heartbeat", - "description": "Check observation thresholds during heartbeat events." - }, - "enableCompactionObservation": { - "label": "Observe On Compaction", - "description": "Force observer flush during compaction events." - }, - "enableWeeklyReflection": { - "label": "Weekly Reflection", - "description": "Run weekly reflection on Sunday midnight UTC." - }, - "enableFactExtraction": { - "label": "Fact Extraction", - "description": "Extract structured facts from hook event payloads." - }, - "autoCheckpoint": { - "label": "Auto Checkpoint (Legacy Alias)", - "description": "Deprecated alias for enableAutoCheckpoint." - }, - "contextProfile": { - "label": "Context Profile", - "description": "Profile used for context injection at session start" - }, - "maxContextResults": { - "label": "Max Context Results", - "description": "Number of vault memories to inject" - }, - "observeOnHeartbeat": { - "label": "Observe on Heartbeat (Legacy Alias)", - "description": "Deprecated alias for enableHeartbeatObservation." - }, - "weeklyReflection": { - "label": "Weekly Reflection (Legacy Alias)", - "description": "Deprecated alias for enableWeeklyReflection." - } - } -} diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 92ff3a2c..a65ec01c 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "clawvault", "name": "ClawVault", - "version": "2.7.0", + "version": "3.5.0", "kind": "memory", "description": "Structured memory system for AI agents with context death resilience", "configSchema": { diff --git a/package.json b/package.json index f9b043d6..218a5542 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawvault", - "version": "3.4.0", + "version": "3.5.0", "description": "Structured memory system for AI agents — typed storage, knowledge graph, context profiles, canvas dashboards, neural graph themes, and Obsidian-native task views. An elephant never forgets. 🐘", "type": "module", "main": "dist/index.cjs", @@ -21,16 +21,12 @@ "bin", "dashboard", "templates", - "hooks", "openclaw.plugin.json" ], "openclaw": { "plugin": "./openclaw.plugin.json", "extensions": [ "./dist/openclaw-plugin.js" - ], - "hooks": [ - "./hooks/clawvault" ] }, "scripts": { diff --git a/packages/plugin/scripts/build-embeddings.mjs b/packages/plugin/scripts/build-embeddings.mjs deleted file mode 100644 index 83aafe37..00000000 --- a/packages/plugin/scripts/build-embeddings.mjs +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -/** - * Build embedding cache for all vault documents. - * Uses Ollama nomic-embed-text model. - */ -import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs'; -import { join, relative } from 'path'; - -const vaultPath = process.env.CLAWVAULT_PATH || join(process.env.HOME, 'clawvault'); -const cachePath = join(vaultPath, '.clawvault', 'embeddings.bin.json'); - -async function getEmbedding(text) { - const resp = await fetch('http://localhost:11434/api/embeddings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'nomic-embed-text', prompt: text.slice(0, 2000) }), - }); - if (!resp.ok) throw new Error(`Ollama error: ${resp.status}`); - const data = await resp.json(); - return data.embedding; -} - -function walkDir(dir, base) { - const files = []; - for (const entry of readdirSync(dir)) { - if (entry.startsWith('.') || entry === 'node_modules') continue; - const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) { - files.push(...walkDir(full, base)); - } else if (entry.endsWith('.md')) { - files.push(relative(base, full)); - } - } - return files; -} - -async function main() { - // Load existing cache - let cache = {}; - if (existsSync(cachePath)) { - try { cache = JSON.parse(readFileSync(cachePath, 'utf-8')); } catch {} - } - - const mdFiles = walkDir(vaultPath, vaultPath) - .filter(f => !f.startsWith('node_modules') && !f.startsWith('.')); - - console.log(`Found ${mdFiles.length} markdown files, ${Object.keys(cache).length} cached`); - - let updated = 0; - for (const file of mdFiles) { - const docId = file.replace(/\.md$/, ''); - if (cache[docId]) continue; // Already cached - - try { - const content = readFileSync(join(vaultPath, file), 'utf-8'); - if (content.length < 20) continue; - - const embedding = await getEmbedding(content); - cache[docId] = embedding; - updated++; - - if (updated % 10 === 0) { - console.log(` Embedded ${updated} new docs...`); - // Save incrementally - const dir = join(vaultPath, '.clawvault'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(cachePath, JSON.stringify(cache)); - } - } catch (err) { - console.error(` Error embedding ${file}: ${err.message}`); - } - } - - // Final save - const dir = join(vaultPath, '.clawvault'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(cachePath, JSON.stringify(cache)); - console.log(`Done. ${Object.keys(cache).length} total embeddings (${updated} new)`); -} - -main().catch(console.error); diff --git a/packages/plugin/scripts/semantic-rerank.mjs b/packages/plugin/scripts/semantic-rerank.mjs deleted file mode 100644 index dd77b493..00000000 --- a/packages/plugin/scripts/semantic-rerank.mjs +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -/** - * Semantic reranking helper for ClawVault plugin. - * Called synchronously via execFileSync. - * - * Usage: node semantic-rerank.mjs <query> <cache-path> <results-json> - * Outputs: JSON array of {docid, score, original_score, semantic_score} sorted by RRF - */ -import { readFileSync, existsSync } from 'fs'; - -const query = process.argv[2]; -const cachePath = process.argv[3]; -const resultsJson = process.argv[4]; - -if (!query || !cachePath || !resultsJson) { - console.log(JSON.stringify([])); - process.exit(0); -} - -// Load embedding cache -let cache; -try { - if (!existsSync(cachePath)) { - // No cache yet — return original results unchanged - console.log(resultsJson); - process.exit(0); - } - cache = JSON.parse(readFileSync(cachePath, 'utf-8')); -} catch { - console.log(resultsJson); - process.exit(0); -} - -// Parse BM25 results -let bm25Results; -try { - bm25Results = JSON.parse(resultsJson); -} catch { - console.log('[]'); - process.exit(0); -} - -// Get query embedding from Ollama -async function getEmbedding(text) { - try { - const resp = await fetch('http://localhost:11434/api/embeddings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'nomic-embed-text', prompt: text }), - }); - if (!resp.ok) return null; - const data = await resp.json(); - return data.embedding; - } catch { - return null; - } -} - -function cosineSim(a, b) { - if (!a || !b || a.length !== b.length) return 0; - let dot = 0, na = 0, nb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10); -} - -async function main() { - const queryEmb = await getEmbedding(query); - if (!queryEmb) { - // Ollama not available — return BM25 results unchanged - console.log(JSON.stringify(bm25Results)); - return; - } - - // Score each cached document by semantic similarity - const semanticScores = new Map(); - for (const [docId, embedding] of Object.entries(cache)) { - semanticScores.set(docId, cosineSim(queryEmb, embedding)); - } - - // RRF fusion (k=60) - const k = 60; - const rrfScores = new Map(); - - // BM25 ranking - for (let rank = 0; rank < bm25Results.length; rank++) { - const docId = bm25Results[rank].docid || bm25Results[rank].file || ''; - rrfScores.set(docId, (rrfScores.get(docId) || 0) + 1 / (k + rank + 1)); - } - - // Semantic ranking (top 30) - const semanticRanked = [...semanticScores.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 30); - - for (let rank = 0; rank < semanticRanked.length; rank++) { - const docId = semanticRanked[rank][0]; - rrfScores.set(docId, (rrfScores.get(docId) || 0) + 1 / (k + rank + 1)); - } - - // Re-rank BM25 results by RRF score - const reranked = bm25Results - .map(r => ({ - ...r, - score: rrfScores.get(r.docid || r.file || '') || r.score, - })) - .sort((a, b) => b.score - a.score); - - // Add any semantic-only results not in BM25 - const bm25Ids = new Set(bm25Results.map(r => r.docid || r.file)); - for (const [docId, rrfScore] of rrfScores.entries()) { - if (!bm25Ids.has(docId) && rrfScore > 0) { - reranked.push({ - docid: docId, - file: docId, - score: rrfScore, - title: docId.split('/').pop() || docId, - snippet: '[semantic match]', - }); - } - } - - reranked.sort((a, b) => b.score - a.score); - console.log(JSON.stringify(reranked)); -} - -main().catch(() => { - console.log(JSON.stringify(bm25Results)); -}); diff --git a/src/capture/service.test.ts b/src/capture/service.test.ts index 23e2a185..0169dc7f 100644 --- a/src/capture/service.test.ts +++ b/src/capture/service.test.ts @@ -52,6 +52,6 @@ describe('LiveCaptureService', () => { expect(firstRun.stored).toBeGreaterThan(0); expect(secondRun.rejected).toBeGreaterThan(0); - }); + }, 20_000); }); diff --git a/src/plugin/clawvault-cli.ts b/src/plugin/clawvault-cli.ts index 0c1d5915..652d7747 100644 --- a/src/plugin/clawvault-cli.ts +++ b/src/plugin/clawvault-cli.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "child_process"; +import { execFileSync, spawn } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { @@ -367,13 +367,45 @@ export function runObserverCron( pluginConfig: ClawVaultPluginConfig, options: { minNewBytes?: number; reason?: string } = {} ): boolean { + if (!isOptInEnabled(pluginConfig, "allowClawvaultExec")) { + return false; + } + + const executablePath = resolveExecutablePath(CLAWVAULT_EXECUTABLE, { + explicitPath: getConfiguredExecutablePath(pluginConfig) + }); + if (!executablePath) { + return false; + } + + const expectedSha256 = getConfiguredExecutableSha256(pluginConfig); + const integrityResult = verifyExecutableIntegrity(executablePath, expectedSha256); + if (!integrityResult.ok) { + return false; + } + const args = ["observe", "--cron", "--agent", agentId, "-v", vaultPath]; if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) { args.push("--min-new", String(Math.floor(Number(options.minNewBytes)))); } - const result = runClawvault(args, pluginConfig, { timeoutMs: 120_000 }); - return !result.skipped && result.success; + let sanitizedArgs: string[]; + try { + sanitizedArgs = sanitizeExecArgs(args); + } catch { + return false; + } + + try { + const child = spawn(executablePath, sanitizedArgs, { + stdio: "ignore", + shell: false + }); + child.unref(); + return true; + } catch { + return false; + } } export function resolveSessionKey(input: unknown): string { diff --git a/src/plugin/hooks/session-lifecycle.test.ts b/src/plugin/hooks/session-lifecycle.test.ts new file mode 100644 index 00000000..0a3ad2ad --- /dev/null +++ b/src/plugin/hooks/session-lifecycle.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ClawVaultPluginRuntimeState } from "../runtime-state.js"; + +const { recoverMock, checkpointMock, flushCheckpointMock, runReflectionMock } = vi.hoisted(() => ({ + recoverMock: vi.fn(), + checkpointMock: vi.fn(), + flushCheckpointMock: vi.fn(), + runReflectionMock: vi.fn() +})); + +vi.mock("../../commands/recover.js", () => ({ + recover: recoverMock +})); + +vi.mock("../../commands/checkpoint.js", () => ({ + checkpoint: checkpointMock, + flush: flushCheckpointMock +})); + +vi.mock("../../observer/reflection-service.js", () => ({ + runReflection: runReflectionMock +})); + +import { handleBeforeReset, handleGatewayStart } from "./session-lifecycle.js"; + +describe("session lifecycle hooks", () => { + const tempDirs: string[] = []; + + beforeEach(() => { + recoverMock.mockReset(); + checkpointMock.mockReset(); + flushCheckpointMock.mockReset(); + runReflectionMock.mockReset(); + }); + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function createTempVault(): string { + const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), "clawvault-session-lifecycle-")); + fs.writeFileSync(path.join(vaultPath, ".clawvault.json"), JSON.stringify({ name: "session-lifecycle-test" }), "utf-8"); + tempDirs.push(vaultPath); + return vaultPath; + } + + it("hydrates startup recovery notice via in-process recover()", async () => { + const runtimeState = new ClawVaultPluginRuntimeState(); + const vaultPath = createTempVault(); + recoverMock.mockResolvedValue({ + died: true, + deathTime: "2026-03-16T00:00:00.000Z", + checkpoint: { timestamp: "2026-03-16T00:00:00.000Z", workingOn: "Deploy canary" }, + handoffPath: null, + handoffContent: null, + recoveryMessage: "Context death" + }); + + await handleGatewayStart( + { port: 3377 }, + {}, + { + pluginConfig: { enableStartupRecovery: true, vaultPath }, + runtimeState, + logger: { info: () => undefined, warn: () => undefined } + } + ); + + expect(recoverMock).toHaveBeenCalledWith(vaultPath, { clearFlag: true }); + const notice = runtimeState.consumeStartupRecoveryNotice(); + expect(notice).toContain("Context death detected"); + expect(notice).toContain("Deploy canary"); + }); + + it("writes auto-checkpoint before reset via in-process checkpoint()", async () => { + const runtimeState = new ClawVaultPluginRuntimeState(); + const vaultPath = createTempVault(); + checkpointMock.mockResolvedValue({ + timestamp: "2026-03-16T00:00:00.000Z", + workingOn: "Session reset via test_reset", + focus: "Pre-reset checkpoint", + blocked: null + }); + flushCheckpointMock.mockResolvedValue(null); + + await handleBeforeReset( + { + reason: "test_reset", + messages: [] + }, + { + sessionKey: "agent:main:session-5", + agentId: "main", + workspaceDir: vaultPath + }, + { + pluginConfig: { + enableAutoCheckpoint: true, + enableObserveOnNew: false, + enableFactExtraction: false, + vaultPath + }, + runtimeState, + logger: { info: () => undefined, warn: () => undefined } + } + ); + + expect(checkpointMock).toHaveBeenCalledWith({ + workingOn: "Session reset via test_reset", + focus: "Pre-reset checkpoint, session: agent:main:session-5", + vaultPath + }); + expect(flushCheckpointMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugin/hooks/session-lifecycle.ts b/src/plugin/hooks/session-lifecycle.ts index 93f58191..7b8c1da5 100644 --- a/src/plugin/hooks/session-lifecycle.ts +++ b/src/plugin/hooks/session-lifecycle.ts @@ -16,14 +16,15 @@ import { fetchSessionRecapEntries } from "../vault-context-injector.js"; import { - parseRecoveryOutput, resolveVaultPathForAgent, - runClawvault, runObserverCron, formatSessionContextInjection } from "../clawvault-cli.js"; import type { ClawVaultPluginRuntimeState } from "../runtime-state.js"; import { runFactExtractionForEvent } from "../fact-extractor.js"; +import { recover as recoverContext } from "../../commands/recover.js"; +import { checkpoint as saveCheckpoint, flush as flushCheckpoint } from "../../commands/checkpoint.js"; +import { runReflection } from "../../observer/reflection-service.js"; export interface SessionLifecycleDependencies { pluginConfig: ClawVaultPluginConfig; @@ -73,13 +74,13 @@ async function runWeeklyReflectionIfNeeded( return; } - const result = runClawvault(["reflect", "-v", vaultPath], deps.pluginConfig, { - timeoutMs: 120_000 - }); - if (result.success) { + try { + const result = await runReflection({ vaultPath }); + if (result.writtenWeeks > 0) { + deps.logger?.info("[clawvault] Weekly reflection complete"); + } deps.runtimeState.markWeeklyReflectionRun(weekKey); - deps.logger?.info("[clawvault] Weekly reflection complete"); - } else if (!result.skipped) { + } catch { deps.logger?.warn("[clawvault] Weekly reflection failed"); } } @@ -100,22 +101,18 @@ export async function handleGatewayStart( return; } - const result = runClawvault(["recover", "--clear", "-v", vaultPath], deps.pluginConfig, { - timeoutMs: 20_000 - }); - if (result.skipped) { - return; - } - - if (!result.success) { + let recoveryInfo: Awaited<ReturnType<typeof recoverContext>>; + try { + recoveryInfo = await recoverContext(vaultPath, { clearFlag: true }); + } catch { deps.logger?.warn("[clawvault] Startup recovery command failed"); return; } - const parsed = parseRecoveryOutput(result.output); - if (parsed.hadDeath) { - const message = parsed.workingOn - ? `[ClawVault] Context death detected. Last working on: ${parsed.workingOn}. Run \`clawvault wake\` for full recovery context.` + if (recoveryInfo.died) { + const workingOn = recoveryInfo.checkpoint?.workingOn?.trim(); + const message = workingOn + ? `[ClawVault] Context death detected. Last working on: ${workingOn}. Run \`clawvault wake\` for full recovery context.` : "[ClawVault] Context death detected. Run `clawvault wake` for full recovery context."; deps.runtimeState.setStartupRecoveryNotice(message); deps.logger?.warn("[clawvault] Context death detected at startup"); @@ -181,16 +178,14 @@ export async function handleBeforeReset( if (autoCheckpointEnabled) { const safeSessionKey = sanitizeForCheckpoint(sessionKey, 120); const safeReason = sanitizeForCheckpoint(event.reason ?? "before_reset", 80); - const checkpointResult = runClawvault([ - "checkpoint", - "--working-on", - `Session reset via ${safeReason}`, - "--focus", - `Pre-reset checkpoint, session: ${safeSessionKey}`, - "-v", - vaultPath - ], deps.pluginConfig, { timeoutMs: 30_000 }); - if (!checkpointResult.success && !checkpointResult.skipped) { + try { + await saveCheckpoint({ + workingOn: `Session reset via ${safeReason}`, + focus: `Pre-reset checkpoint, session: ${safeSessionKey}`, + vaultPath + }); + await flushCheckpoint(); + } catch { deps.logger?.warn("[clawvault] Auto-checkpoint before reset failed"); } } diff --git a/src/plugin/vault-context-injector.test.ts b/src/plugin/vault-context-injector.test.ts new file mode 100644 index 00000000..5822a41c --- /dev/null +++ b/src/plugin/vault-context-injector.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const { buildSessionRecapMock, loadMock, findMock, ClawVaultMock } = vi.hoisted(() => ({ + buildSessionRecapMock: vi.fn(), + loadMock: vi.fn(), + findMock: vi.fn(), + ClawVaultMock: vi.fn() +})); + +vi.mock("../commands/session-recap.js", () => ({ + buildSessionRecap: buildSessionRecapMock +})); + +vi.mock("../lib/vault.js", () => ({ + ClawVault: ClawVaultMock +})); + +import { fetchMemoryContextEntries, fetchSessionRecapEntries } from "./vault-context-injector.js"; + +describe("vault context injector", () => { + const tempDirs: string[] = []; + + beforeEach(() => { + loadMock.mockReset(); + findMock.mockReset(); + ClawVaultMock.mockReset(); + ClawVaultMock.mockImplementation(() => ({ + load: loadMock, + find: findMock + })); + buildSessionRecapMock.mockReset(); + }); + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function createTempVault(): string { + const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), "clawvault-injector-")); + fs.writeFileSync(path.join(vaultPath, ".clawvault.json"), JSON.stringify({ name: "injector-test" }), "utf-8"); + tempDirs.push(vaultPath); + return vaultPath; + } + + it("uses in-process ClawVault.find for memory context entries", async () => { + const vaultPath = createTempVault(); + findMock.mockResolvedValue([ + { + document: { + id: "decisions/release-plan", + path: path.join(vaultPath, "decisions", "release-plan.md"), + category: "decisions", + title: "Release plan", + content: "Canary first, then global rollout.", + frontmatter: {}, + links: [], + tags: [], + modified: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) + }, + score: 0.91, + snippet: "Decision: Canary first, then global rollout.", + matchedTerms: ["canary", "rollout"] + } + ]); + + const result = await fetchMemoryContextEntries({ + prompt: " What release rollout did we choose? ", + pluginConfig: { vaultPath }, + maxResults: 5 + }); + + expect(ClawVaultMock).toHaveBeenCalledWith(vaultPath); + expect(loadMock).toHaveBeenCalledTimes(1); + expect(findMock).toHaveBeenCalledWith("What release rollout did we choose?", { + limit: 5, + minScore: 0.2, + temporalBoost: true + }); + expect(result.vaultPath).toBe(vaultPath); + expect(result.entries).toHaveLength(1); + expect(result.entries[0]?.title).toBe("Release plan"); + expect(result.entries[0]?.path).toBe("decisions/release-plan.md"); + expect(result.entries[0]?.snippet).toContain("Canary first"); + }); + + it("maps recap messages via buildSessionRecap", async () => { + buildSessionRecapMock.mockResolvedValue({ + messages: [ + { role: "user", text: "Please summarize deployment status." }, + { role: "assistant", text: "Deployment is in canary phase." } + ] + }); + + const entries = await fetchSessionRecapEntries({ + sessionKey: "agent:main:session-42", + agentId: "main", + pluginConfig: {} + }); + + expect(buildSessionRecapMock).toHaveBeenCalledWith("agent:main:session-42", { + agentId: "main", + limit: 6 + }); + expect(entries).toEqual([ + { role: "User", text: "Please summarize deployment status." }, + { role: "Assistant", text: "Deployment is in canary phase." } + ]); + }); +}); diff --git a/src/plugin/vault-context-injector.ts b/src/plugin/vault-context-injector.ts index 60c7a3dd..650b1e00 100644 --- a/src/plugin/vault-context-injector.ts +++ b/src/plugin/vault-context-injector.ts @@ -1,8 +1,81 @@ +import * as path from "path"; +import { ClawVault } from "../lib/vault.js"; +import { buildSessionRecap } from "../commands/session-recap.js"; +import type { SearchResult } from "../types.js"; import type { ClawVaultPluginConfig } from "./config.js"; -import { resolveVaultPathForAgent, runClawvault, parseContextJson, parseSessionRecapJson, formatSessionContextInjection, sanitizePromptForContext, resolveSessionKey, type ContextEntry, type SessionRecapEntry } from "./clawvault-cli.js"; +import { + resolveVaultPathForAgent, + formatSessionContextInjection, + sanitizeForDisplay, + sanitizePromptForContext, + resolveSessionKey, + type ContextEntry, + type SessionRecapEntry +} from "./clawvault-cli.js"; const DEFAULT_MAX_CONTEXT_RESULTS = 4; const DEFAULT_MAX_RECAP_RESULTS = 6; +const DEFAULT_MIN_SCORE = 0.2; +const MAX_CONTEXT_SNIPPET_LENGTH = 220; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function truncateContextSnippet(snippet: string): string { + const normalized = sanitizeForDisplay(snippet).replace(/\s+/g, " ").trim(); + if (normalized.length <= MAX_CONTEXT_SNIPPET_LENGTH) { + return normalized; + } + return `${normalized.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`; +} + +function toRelativeVaultPath(vaultPath: string, absolutePath: string): string { + const rel = path.relative(vaultPath, absolutePath).replace(/\\/g, "/"); + return rel.startsWith(".") ? path.basename(absolutePath) : rel; +} + +function formatAgeLabel(modifiedAt: Date): string { + const modified = modifiedAt.getTime(); + if (!Number.isFinite(modified)) { + return "unknown age"; + } + + const elapsedMs = Date.now() - modified; + if (elapsedMs < ONE_DAY_MS) { + return "today"; + } + const days = Math.max(1, Math.floor(elapsedMs / ONE_DAY_MS)); + if (days < 7) { + return `${days}d ago`; + } + const weeks = Math.floor(days / 7); + if (weeks < 5) { + return `${weeks}w ago`; + } + const months = Math.floor(days / 30); + if (months < 12) { + return `${months}mo ago`; + } + const years = Math.floor(days / 365); + return `${years}y ago`; +} + +function mapSearchResultToContextEntry(vaultPath: string, result: SearchResult): ContextEntry | null { + const snippet = truncateContextSnippet(result.snippet); + if (!snippet) { + return null; + } + + return { + title: sanitizeForDisplay(result.document.title || "Untitled"), + path: sanitizeForDisplay(toRelativeVaultPath(vaultPath, result.document.path)), + age: formatAgeLabel(result.document.modified), + snippet, + score: Number.isFinite(result.score) ? result.score : 0 + }; +} export interface VaultContextInjectorOptions { prompt: string; @@ -27,17 +100,25 @@ export async function fetchSessionRecapEntries( const sessionKey = resolveSessionKey(options.sessionKey); if (!sessionKey) return []; - const recapArgs = ["session-recap", sessionKey, "--format", "json"]; - if (options.agentId) { - recapArgs.push("--agent", options.agentId); - } - - const recapResult = runClawvault(recapArgs, options.pluginConfig, { timeoutMs: 20_000 }); - if (!recapResult.success) { + try { + const recap = await buildSessionRecap(sessionKey, { + agentId: options.agentId, + limit: DEFAULT_MAX_RECAP_RESULTS + }); + return recap.messages + .slice(-DEFAULT_MAX_RECAP_RESULTS) + .map((entry) => { + const text = sanitizeForDisplay(entry.text); + if (!text) return null; + return { + role: entry.role === "user" ? "User" : "Assistant", + text + } satisfies SessionRecapEntry; + }) + .filter((entry): entry is SessionRecapEntry => Boolean(entry)); + } catch { return []; } - - return parseSessionRecapJson(recapResult.output, DEFAULT_MAX_RECAP_RESULTS); } export async function fetchMemoryContextEntries( @@ -57,31 +138,24 @@ export async function fetchMemoryContextEntries( } const maxResults = Number.isFinite(options.maxResults) - ? Math.max(1, Math.min(20, Number(options.maxResults))) + ? clamp(Math.floor(Number(options.maxResults)), 1, 20) : DEFAULT_MAX_CONTEXT_RESULTS; - const profile = options.contextProfile ?? options.pluginConfig.contextProfile ?? "auto"; - - const contextArgs = [ - "context", - prompt, - "--format", - "json", - "--profile", - profile, - "--limit", - String(maxResults), - "-v", - vaultPath - ]; - const contextResult = runClawvault(contextArgs, options.pluginConfig, { timeoutMs: 25_000 }); - if (!contextResult.success) { + + try { + const vault = new ClawVault(vaultPath); + await vault.load(); + const matches = await vault.find(prompt, { + limit: maxResults, + minScore: DEFAULT_MIN_SCORE, + temporalBoost: true + }); + const entries = matches + .map((match) => mapSearchResultToContextEntry(vaultPath, match)) + .filter((entry): entry is ContextEntry => Boolean(entry)); + return { entries, vaultPath }; + } catch { return { entries: [], vaultPath }; } - - return { - entries: parseContextJson(contextResult.output, maxResults), - vaultPath - }; } export async function buildVaultContextInjection( diff --git a/tests/clawvault-hook-facts.test.js b/tests/clawvault-hook-facts.test.js deleted file mode 100644 index 0e863f38..00000000 --- a/tests/clawvault-hook-facts.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -const tempDirs = []; - -const { execFileSyncMock, resolveExecutablePathMock, verifyExecutableIntegrityMock, sanitizeExecArgsMock } = vi.hoisted(() => ({ - execFileSyncMock: vi.fn(), - resolveExecutablePathMock: vi.fn(), - verifyExecutableIntegrityMock: vi.fn(), - sanitizeExecArgsMock: vi.fn() -})); - -vi.mock('child_process', () => ({ - execFileSync: execFileSyncMock -})); - -vi.mock('../hooks/clawvault/integrity.js', () => ({ - resolveExecutablePath: resolveExecutablePathMock, - verifyExecutableIntegrity: verifyExecutableIntegrityMock, - sanitizeExecArgs: sanitizeExecArgsMock -})); - -async function loadHandler() { - vi.resetModules(); - const module = await import('../hooks/clawvault/handler.js'); - return module.default; -} - -function makeVault() { - const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-hook-facts-')); - fs.writeFileSync(path.join(vaultPath, '.clawvault.json'), JSON.stringify({ name: 'test' }), 'utf-8'); - tempDirs.push(vaultPath); - return vaultPath; -} - -function readFacts(vaultPath) { - const factsPath = path.join(vaultPath, '.clawvault', 'facts.jsonl'); - const raw = fs.readFileSync(factsPath, 'utf-8'); - return raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line)); -} - -beforeEach(() => { - execFileSyncMock.mockReset(); - execFileSyncMock.mockReturnValue('ok'); - resolveExecutablePathMock.mockReset(); - resolveExecutablePathMock.mockReturnValue('/usr/local/bin/clawvault'); - verifyExecutableIntegrityMock.mockReset(); - verifyExecutableIntegrityMock.mockReturnValue({ ok: true, actualSha256: 'a'.repeat(64) }); - sanitizeExecArgsMock.mockReset(); - sanitizeExecArgsMock.mockImplementation((args) => args); -}); - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - vi.clearAllMocks(); -}); - -describe('clawvault hook fact extraction', () => { - it('extracts structured facts and writes entity graph on command:new', async () => { - const handler = await loadHandler(); - const vaultPath = makeVault(); - - await handler({ - type: 'command', - action: 'new', - sessionKey: 'agent:main:session-1', - pluginConfig: { - vaultPath, - allowClawvaultExec: true, - enableFactExtraction: true, - enableAutoCheckpoint: true, - enableObserveOnNew: true - }, - context: { - commandSource: 'cli', - messages: [ - { - role: 'user', - content: 'I prefer dark mode. I am allergic to peanuts. I work at Acme Corp. I live in Lisbon. I am 33 years old. I bought a MacBook Pro. I spent $1200 on a monitor. I decided to use PostgreSQL. My partner is Alice. My dog is Bruno. My mother is Carol.' - } - ] - } - }); - - const factsPath = path.join(vaultPath, '.clawvault', 'facts.jsonl'); - const graphPath = path.join(vaultPath, '.clawvault', 'entity-graph.json'); - - expect(fs.existsSync(factsPath)).toBe(true); - expect(fs.existsSync(graphPath)).toBe(true); - - const facts = readFacts(vaultPath); - expect(facts.length).toBeGreaterThanOrEqual(11); - - const relations = new Set(facts.map((fact) => fact.relation)); - const expectedRelations = [ - 'favorite_preference', - 'allergic_to', - 'works_at', - 'lives_in', - 'age', - 'bought', - 'spent', - 'decided', - 'partner_name', - 'dog_name', - 'parent_name' - ]; - for (const relation of expectedRelations) { - expect(relations.has(relation)).toBe(true); - } - - for (const fact of facts) { - expect(typeof fact.id).toBe('string'); - expect(typeof fact.entity).toBe('string'); - expect(typeof fact.entityNorm).toBe('string'); - expect(typeof fact.relation).toBe('string'); - expect(typeof fact.value).toBe('string'); - expect(typeof fact.validFrom).toBe('string'); - expect('validUntil' in fact).toBe(true); - expect(typeof fact.confidence).toBe('number'); - expect(typeof fact.category).toBe('string'); - expect(typeof fact.source).toBe('string'); - expect(typeof fact.rawText).toBe('string'); - } - - const graph = JSON.parse(fs.readFileSync(graphPath, 'utf-8')); - expect(graph.version).toBe(1); - expect(Array.isArray(graph.nodes)).toBe(true); - expect(Array.isArray(graph.edges)).toBe(true); - expect(graph.nodes.length).toBeGreaterThan(0); - expect(graph.edges.length).toBeGreaterThan(0); - }); - - it('supersedes old values for exclusive relations and closes prior facts', async () => { - const handler = await loadHandler(); - const vaultPath = makeVault(); - - await handler({ - type: 'compaction', - action: 'memoryFlush', - pluginConfig: { - vaultPath, - allowClawvaultExec: true, - enableFactExtraction: true, - enableCompactionObservation: true - }, - context: { - messages: [{ role: 'user', content: 'I live in Lisbon. I prefer tea.' }] - } - }); - - await handler({ - type: 'command', - action: 'new', - sessionKey: 'agent:main:session-2', - pluginConfig: { - vaultPath, - allowClawvaultExec: true, - enableFactExtraction: true, - enableAutoCheckpoint: true, - enableObserveOnNew: true - }, - context: { - commandSource: 'cli', - messages: [{ role: 'user', content: 'I live in Porto. I prefer coffee.' }] - } - }); - - const facts = readFacts(vaultPath); - const livesIn = facts.filter((fact) => fact.entityNorm === 'user' && fact.relation === 'lives_in'); - expect(livesIn.length).toBe(2); - - const lisbon = livesIn.find((fact) => fact.value.toLowerCase() === 'lisbon'); - const porto = livesIn.find((fact) => fact.value.toLowerCase() === 'porto'); - expect(lisbon?.validUntil).toBeTruthy(); - expect(porto?.validUntil).toBeNull(); - - const favorites = facts.filter((fact) => fact.entityNorm === 'user' && fact.relation === 'favorite_preference'); - expect(favorites.length).toBe(2); - const tea = favorites.find((fact) => fact.value.toLowerCase() === 'tea'); - const coffee = favorites.find((fact) => fact.value.toLowerCase() === 'coffee'); - expect(tea?.validUntil).toBeTruthy(); - expect(coffee?.validUntil).toBeNull(); - - const graphPath = path.join(vaultPath, '.clawvault', 'entity-graph.json'); - const graph = JSON.parse(fs.readFileSync(graphPath, 'utf-8')); - const closedLivesInEdge = graph.edges.find((edge) => edge.relation === 'lives_in' && edge.validUntil); - const activeLivesInEdge = graph.edges.find((edge) => edge.relation === 'lives_in' && edge.validUntil === null); - expect(closedLivesInEdge).toBeTruthy(); - expect(activeLivesInEdge).toBeTruthy(); - }); -});