clawvault 2.0.2 β†’ 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,11 @@ interface ObserveCommandOptions {
8
8
  compress?: string;
9
9
  daemon?: boolean;
10
10
  vaultPath?: string;
11
+ active?: boolean;
12
+ agent?: string;
13
+ minNew?: number;
14
+ sessionsDir?: string;
15
+ dryRun?: boolean;
11
16
  }
12
17
  declare function observeCommand(options: ObserveCommandOptions): Promise<void>;
13
18
  declare function registerObserveCommand(program: Command): void;
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  observeCommand,
3
3
  registerObserveCommand
4
- } from "../chunk-BAS32WLF.js";
5
- import "../chunk-NU7VKTWG.js";
4
+ } from "../chunk-YHKGPRII.js";
5
+ import "../chunk-HRLWZGMA.js";
6
+ import "../chunk-MVX4NNVJ.js";
7
+ import "../chunk-MXSSG3QU.js";
6
8
  export {
7
9
  observeCommand,
8
10
  registerObserveCommand
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Observer,
3
3
  parseSessionFile
4
- } from "../chunk-NU7VKTWG.js";
4
+ } from "../chunk-MVX4NNVJ.js";
5
5
  import {
6
6
  clearDirtyFlag
7
7
  } from "../chunk-MZZJLQNQ.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  SessionWatcher,
7
7
  observeCommand,
8
8
  registerObserveCommand
9
- } from "./chunk-BAS32WLF.js";
9
+ } from "./chunk-YHKGPRII.js";
10
10
  import {
11
11
  buildSessionRecap,
12
12
  formatSessionRecapMarkdown,
@@ -21,7 +21,7 @@ import {
21
21
  Observer,
22
22
  Reflector,
23
23
  parseSessionFile
24
- } from "./chunk-NU7VKTWG.js";
24
+ } from "./chunk-MVX4NNVJ.js";
25
25
  import {
26
26
  buildContext,
27
27
  contextCommand,
@@ -4,7 +4,7 @@ description: "Context resilience - recovery detection, auto-checkpoint, and sess
4
4
  metadata:
5
5
  openclaw:
6
6
  emoji: "🐘"
7
- events: ["gateway:startup", "command:new", "session:start"]
7
+ events: ["gateway:startup", "gateway:heartbeat", "command:new", "session:start", "compaction:memoryFlush"]
8
8
  requires:
9
9
  bins: ["clawvault"]
10
10
  ---
@@ -14,7 +14,9 @@ metadata:
14
14
  Integrates ClawVault's context death resilience into OpenClaw:
15
15
 
16
16
  - **On gateway startup**: Checks for context death, alerts agent
17
+ - **On heartbeat**: Runs cheap threshold checks and observes active sessions when needed
17
18
  - **On /new command**: Auto-checkpoints before session reset
19
+ - **On context compaction**: Forces incremental observation flush before context is lost
18
20
  - **On session start**: Injects relevant vault context for the initial prompt
19
21
 
20
22
  ## Installation
@@ -61,7 +63,7 @@ Injection format:
61
63
 
62
64
  ### Event Compatibility
63
65
 
64
- The hook accepts canonical OpenClaw events (`gateway:startup`, `command:new`, `session:start`) and tolerates alias payload shapes (`event`, `eventName`, `name`, `hook`, `trigger`) to remain robust across runtime wrappers.
66
+ The hook accepts canonical OpenClaw events (`gateway:startup`, `gateway:heartbeat`, `command:new`, `session:start`, `compaction:memoryFlush`) and tolerates alias payload shapes (`event`, `eventName`, `name`, `hook`, `trigger`) to remain robust across runtime wrappers.
65
67
 
66
68
  ## No Configuration Needed
67
69
 
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Provides automatic context death resilience:
5
5
  * - gateway:startup β†’ detect context death, inject recovery info
6
+ * - gateway:heartbeat β†’ cheap active-session threshold checks
6
7
  * - command:new β†’ auto-checkpoint before session reset
8
+ * - compaction:memoryFlush β†’ force active-session flush before compaction
7
9
  * - session:start β†’ inject relevant context for first user prompt
8
10
  *
9
11
  * SECURITY: Uses execFileSync (no shell) to prevent command injection
@@ -11,6 +13,7 @@
11
13
 
12
14
  import { execFileSync } from 'child_process';
13
15
  import * as fs from 'fs';
16
+ import * as os from 'os';
14
17
  import * as path from 'path';
15
18
 
16
19
  const MAX_CONTEXT_RESULTS = 4;
@@ -19,6 +22,12 @@ const MAX_CONTEXT_SNIPPET_LENGTH = 220;
19
22
  const MAX_RECAP_RESULTS = 6;
20
23
  const MAX_RECAP_SNIPPET_LENGTH = 220;
21
24
  const EVENT_NAME_SEPARATOR_RE = /[.:/]/g;
25
+ const OBSERVE_CURSOR_FILE = 'observe-cursors.json';
26
+ const ONE_KIB = 1024;
27
+ const ONE_MIB = ONE_KIB * ONE_KIB;
28
+ const SMALL_SESSION_THRESHOLD_BYTES = 50 * ONE_KIB;
29
+ const MEDIUM_SESSION_THRESHOLD_BYTES = 150 * ONE_KIB;
30
+ const LARGE_SESSION_THRESHOLD_BYTES = 300 * ONE_KIB;
22
31
 
23
32
  // Sanitize string for safe display (prevent prompt injection via control chars)
24
33
  function sanitizeForDisplay(str) {
@@ -74,6 +83,128 @@ function extractAgentIdFromSessionKey(sessionKey) {
74
83
  return agentId;
75
84
  }
76
85
 
86
+ function sanitizeAgentId(agentId) {
87
+ if (typeof agentId !== 'string') return '';
88
+ const normalized = agentId.trim();
89
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(normalized)) return '';
90
+ return normalized;
91
+ }
92
+
93
+ function normalizeAbsoluteEnvPath(value) {
94
+ if (typeof value !== 'string') return null;
95
+ const trimmed = value.trim();
96
+ if (!trimmed) return null;
97
+ const resolved = path.resolve(trimmed);
98
+ if (!path.isAbsolute(resolved)) return null;
99
+ return resolved;
100
+ }
101
+
102
+ function getOpenClawAgentsDir() {
103
+ const stateDir = normalizeAbsoluteEnvPath(process.env.OPENCLAW_STATE_DIR);
104
+ if (stateDir) {
105
+ return path.join(stateDir, 'agents');
106
+ }
107
+
108
+ const openClawHome = normalizeAbsoluteEnvPath(process.env.OPENCLAW_HOME);
109
+ if (openClawHome) {
110
+ return path.join(openClawHome, 'agents');
111
+ }
112
+
113
+ return path.join(os.homedir(), '.openclaw', 'agents');
114
+ }
115
+
116
+ function getObserveCursorPath(vaultPath) {
117
+ return path.join(vaultPath, '.clawvault', OBSERVE_CURSOR_FILE);
118
+ }
119
+
120
+ function loadObserveCursors(vaultPath) {
121
+ const cursorPath = getObserveCursorPath(vaultPath);
122
+ if (!fs.existsSync(cursorPath)) {
123
+ return {};
124
+ }
125
+
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(cursorPath, 'utf-8'));
128
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
129
+ return {};
130
+ }
131
+ return parsed;
132
+ } catch {
133
+ return {};
134
+ }
135
+ }
136
+
137
+ function getScaledObservationThresholdBytes(fileSizeBytes) {
138
+ if (!Number.isFinite(fileSizeBytes) || fileSizeBytes <= 0) {
139
+ return SMALL_SESSION_THRESHOLD_BYTES;
140
+ }
141
+ if (fileSizeBytes < ONE_MIB) {
142
+ return SMALL_SESSION_THRESHOLD_BYTES;
143
+ }
144
+ if (fileSizeBytes <= 5 * ONE_MIB) {
145
+ return MEDIUM_SESSION_THRESHOLD_BYTES;
146
+ }
147
+ return LARGE_SESSION_THRESHOLD_BYTES;
148
+ }
149
+
150
+ function parseSessionIndex(agentId) {
151
+ const sessionsDir = path.join(getOpenClawAgentsDir(), agentId, 'sessions');
152
+ const sessionsJsonPath = path.join(sessionsDir, 'sessions.json');
153
+ if (!fs.existsSync(sessionsJsonPath)) {
154
+ return { sessionsDir, index: {} };
155
+ }
156
+
157
+ try {
158
+ const parsed = JSON.parse(fs.readFileSync(sessionsJsonPath, 'utf-8'));
159
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
160
+ return { sessionsDir, index: {} };
161
+ }
162
+ return { sessionsDir, index: parsed };
163
+ } catch {
164
+ return { sessionsDir, index: {} };
165
+ }
166
+ }
167
+
168
+ function shouldObserveActiveSessions(vaultPath, agentId) {
169
+ const cursors = loadObserveCursors(vaultPath);
170
+ const { sessionsDir, index } = parseSessionIndex(agentId);
171
+ const entries = Object.entries(index);
172
+ if (entries.length === 0) {
173
+ return false;
174
+ }
175
+
176
+ for (const [sessionKey, value] of entries) {
177
+ if (!value || typeof value !== 'object') continue;
178
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId.trim() : '';
179
+ if (!/^[a-zA-Z0-9._-]{1,200}$/.test(sessionId)) continue;
180
+
181
+ const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
182
+ let stat;
183
+ try {
184
+ stat = fs.statSync(filePath);
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (!stat.isFile()) continue;
189
+
190
+ const fileSize = stat.size;
191
+ const cursorEntry = cursors[sessionId];
192
+ const previousOffset = Number.isFinite(cursorEntry?.lastObservedOffset)
193
+ ? Math.max(0, Number(cursorEntry.lastObservedOffset))
194
+ : 0;
195
+ const startOffset = previousOffset <= fileSize ? previousOffset : 0;
196
+ const newBytes = Math.max(0, fileSize - startOffset);
197
+ const thresholdBytes = getScaledObservationThresholdBytes(fileSize);
198
+
199
+ if (newBytes >= thresholdBytes) {
200
+ console.log(`[clawvault] Active observe trigger: ${sessionKey} (+${newBytes}B >= ${thresholdBytes}B)`);
201
+ return true;
202
+ }
203
+ }
204
+
205
+ return false;
206
+ }
207
+
77
208
  function extractTextFromMessage(message) {
78
209
  if (typeof message === 'string') return message;
79
210
  if (!message || typeof message !== 'object') return '';
@@ -268,6 +399,26 @@ function eventMatches(event, type, action) {
268
399
  return false;
269
400
  }
270
401
 
402
+ function eventIncludesToken(event, token) {
403
+ const normalizedToken = normalizeEventToken(token);
404
+ if (!normalizedToken) return false;
405
+
406
+ const values = [
407
+ event?.type,
408
+ event?.action,
409
+ event?.event,
410
+ event?.name,
411
+ event?.hook,
412
+ event?.trigger,
413
+ event?.eventName
414
+ ];
415
+
416
+ return values
417
+ .map((value) => normalizeEventToken(value))
418
+ .filter(Boolean)
419
+ .some((value) => value.includes(normalizedToken));
420
+ }
421
+
271
422
  // Validate vault path - must be absolute and exist
272
423
  function validateVaultPath(vaultPath) {
273
424
  if (!vaultPath || typeof vaultPath !== 'string') return null;
@@ -320,13 +471,14 @@ function findVaultPath() {
320
471
  }
321
472
 
322
473
  // Run clawvault command safely (no shell)
323
- function runClawvault(args) {
474
+ function runClawvault(args, options = {}) {
475
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, Number(options.timeoutMs)) : 15000;
324
476
  try {
325
477
  // Use execFileSync to avoid shell injection
326
478
  // Arguments are passed as array, not interpolated into shell
327
479
  const output = execFileSync('clawvault', args, {
328
480
  encoding: 'utf-8',
329
- timeout: 15000,
481
+ timeout: timeoutMs,
330
482
  stdio: ['pipe', 'pipe', 'pipe'],
331
483
  // Explicitly no shell
332
484
  shell: false
@@ -366,6 +518,32 @@ function parseRecoveryOutput(output) {
366
518
  return { hadDeath, workingOn };
367
519
  }
368
520
 
521
+ function resolveAgentIdForEvent(event) {
522
+ const fromSessionKey = extractAgentIdFromSessionKey(extractSessionKey(event));
523
+ if (fromSessionKey) return fromSessionKey;
524
+
525
+ const fromEnv = sanitizeAgentId(process.env.OPENCLAW_AGENT_ID);
526
+ if (fromEnv) return fromEnv;
527
+
528
+ return 'clawdious';
529
+ }
530
+
531
+ function runActiveObservation(vaultPath, agentId, options = {}) {
532
+ const args = ['observe', '--active', '--agent', agentId, '-v', vaultPath];
533
+ if (Number.isFinite(options.minNewBytes) && Number(options.minNewBytes) > 0) {
534
+ args.push('--min-new', String(Math.floor(Number(options.minNewBytes))));
535
+ }
536
+
537
+ const result = runClawvault(args, { timeoutMs: 120000 });
538
+ if (!result.success) {
539
+ console.warn(`[clawvault] Active observation failed (${options.reason || 'unknown reason'})`);
540
+ return false;
541
+ }
542
+
543
+ console.log(`[clawvault] Active observation complete (${options.reason || 'triggered'})`);
544
+ return true;
545
+ }
546
+
369
547
  // Handle gateway startup - check for context death
370
548
  async function handleStartup(event) {
371
549
  const vaultPath = findVaultPath();
@@ -436,6 +614,12 @@ async function handleNew(event) {
436
614
  } else {
437
615
  console.warn('[clawvault] Auto-checkpoint failed');
438
616
  }
617
+
618
+ const agentId = resolveAgentIdForEvent(event);
619
+ runActiveObservation(vaultPath, agentId, {
620
+ minNewBytes: 1,
621
+ reason: 'command:new flush'
622
+ });
439
623
  }
440
624
 
441
625
  // Handle session start - inject dynamic context for first prompt
@@ -500,6 +684,38 @@ async function handleSessionStart(event) {
500
684
  }
501
685
  }
502
686
 
687
+ // Handle heartbeat events - cheap stat-based trigger for active observation
688
+ async function handleHeartbeat(event) {
689
+ const vaultPath = findVaultPath();
690
+ if (!vaultPath) {
691
+ console.log('[clawvault] No vault found, skipping heartbeat observation check');
692
+ return;
693
+ }
694
+
695
+ const agentId = resolveAgentIdForEvent(event);
696
+ if (!shouldObserveActiveSessions(vaultPath, agentId)) {
697
+ console.log('[clawvault] Heartbeat: no sessions crossed active-observe threshold');
698
+ return;
699
+ }
700
+
701
+ runActiveObservation(vaultPath, agentId, { reason: 'heartbeat threshold crossed' });
702
+ }
703
+
704
+ // Handle context compaction - force flush any pending session deltas
705
+ async function handleContextCompaction(event) {
706
+ const vaultPath = findVaultPath();
707
+ if (!vaultPath) {
708
+ console.log('[clawvault] No vault found, skipping compaction observation');
709
+ return;
710
+ }
711
+
712
+ const agentId = resolveAgentIdForEvent(event);
713
+ runActiveObservation(vaultPath, agentId, {
714
+ minNewBytes: 1,
715
+ reason: 'context compaction'
716
+ });
717
+ }
718
+
503
719
  // Main handler - route events
504
720
  const handler = async (event) => {
505
721
  try {
@@ -508,6 +724,26 @@ const handler = async (event) => {
508
724
  return;
509
725
  }
510
726
 
727
+ if (
728
+ eventMatches(event, 'gateway', 'heartbeat')
729
+ || eventMatches(event, 'session', 'heartbeat')
730
+ || eventIncludesToken(event, 'heartbeat')
731
+ ) {
732
+ await handleHeartbeat(event);
733
+ return;
734
+ }
735
+
736
+ if (
737
+ eventMatches(event, 'compaction', 'memoryflush')
738
+ || eventMatches(event, 'context', 'compaction')
739
+ || eventMatches(event, 'context', 'compact')
740
+ || eventIncludesToken(event, 'compaction')
741
+ || eventIncludesToken(event, 'memoryflush')
742
+ ) {
743
+ await handleContextCompaction(event);
744
+ return;
745
+ }
746
+
511
747
  if (eventMatches(event, 'command', 'new')) {
512
748
  await handleNew(event);
513
749
  return;
@@ -17,6 +17,26 @@ function makeVaultFixture() {
17
17
  return root;
18
18
  }
19
19
 
20
+ function makeOpenClawSessionFixture(agentId, sessionId, transcriptBytes = 0) {
21
+ const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-openclaw-'));
22
+ const sessionsDir = path.join(stateRoot, 'agents', agentId, 'sessions');
23
+ fs.mkdirSync(sessionsDir, { recursive: true });
24
+ fs.writeFileSync(
25
+ path.join(sessionsDir, 'sessions.json'),
26
+ JSON.stringify({
27
+ [`agent:${agentId}:main`]: {
28
+ sessionId,
29
+ updatedAt: Date.now()
30
+ }
31
+ }),
32
+ 'utf-8'
33
+ );
34
+ const transcriptPath = path.join(sessionsDir, `${sessionId}.jsonl`);
35
+ const payload = transcriptBytes > 0 ? 'x'.repeat(transcriptBytes) : '';
36
+ fs.writeFileSync(transcriptPath, payload, 'utf-8');
37
+ return { stateRoot, sessionsDir, transcriptPath };
38
+ }
39
+
20
40
  async function loadHandler() {
21
41
  vi.resetModules();
22
42
  const mod = await import('./handler.js');
@@ -26,6 +46,9 @@ async function loadHandler() {
26
46
  afterEach(() => {
27
47
  vi.clearAllMocks();
28
48
  delete process.env.CLAWVAULT_PATH;
49
+ delete process.env.OPENCLAW_STATE_DIR;
50
+ delete process.env.OPENCLAW_HOME;
51
+ delete process.env.OPENCLAW_AGENT_ID;
29
52
  });
30
53
 
31
54
  describe('clawvault hook handler', () => {
@@ -158,4 +181,63 @@ describe('clawvault hook handler', () => {
158
181
 
159
182
  fs.rmSync(vaultPath, { recursive: true, force: true });
160
183
  });
184
+
185
+ it('triggers active observation on heartbeat when threshold is crossed', async () => {
186
+ const vaultPath = makeVaultFixture();
187
+ const sessionId = 'heartbeat-session-1';
188
+ const openClawFixture = makeOpenClawSessionFixture('clawdious', sessionId, 70 * 1024);
189
+ process.env.CLAWVAULT_PATH = vaultPath;
190
+ process.env.OPENCLAW_STATE_DIR = openClawFixture.stateRoot;
191
+
192
+ fs.mkdirSync(path.join(vaultPath, '.clawvault'), { recursive: true });
193
+ fs.writeFileSync(
194
+ path.join(vaultPath, '.clawvault', 'observe-cursors.json'),
195
+ JSON.stringify({
196
+ [sessionId]: {
197
+ lastObservedOffset: 0,
198
+ lastObservedAt: '2026-02-14T00:00:00.000Z',
199
+ sessionKey: 'agent:clawdious:main',
200
+ lastFileSize: 0
201
+ }
202
+ }),
203
+ 'utf-8'
204
+ );
205
+
206
+ execFileSyncMock.mockReturnValue('');
207
+
208
+ const handler = await loadHandler();
209
+ await handler({
210
+ type: 'gateway',
211
+ action: 'heartbeat'
212
+ });
213
+
214
+ expect(execFileSyncMock).toHaveBeenCalledWith(
215
+ 'clawvault',
216
+ expect.arrayContaining(['observe', '--active', '--agent', 'clawdious']),
217
+ expect.objectContaining({ shell: false })
218
+ );
219
+
220
+ fs.rmSync(vaultPath, { recursive: true, force: true });
221
+ fs.rmSync(openClawFixture.stateRoot, { recursive: true, force: true });
222
+ });
223
+
224
+ it('forces active observation flush on compaction events', async () => {
225
+ const vaultPath = makeVaultFixture();
226
+ process.env.CLAWVAULT_PATH = vaultPath;
227
+ execFileSyncMock.mockReturnValue('');
228
+
229
+ const handler = await loadHandler();
230
+ await handler({
231
+ eventName: 'compaction:memoryFlush',
232
+ sessionKey: 'agent:clawdious:main'
233
+ });
234
+
235
+ expect(execFileSyncMock).toHaveBeenCalledWith(
236
+ 'clawvault',
237
+ expect.arrayContaining(['observe', '--active', '--min-new', '1']),
238
+ expect.objectContaining({ shell: false })
239
+ );
240
+
241
+ fs.rmSync(vaultPath, { recursive: true, force: true });
242
+ });
161
243
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawvault",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "ClawVaultβ„’ - 🐘 An elephant never forgets. Structured memory for OpenClaw agents. Context death resilience, Obsidian-compatible markdown, local semantic search.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",