clawvault 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/clawvault.js CHANGED
@@ -384,6 +384,29 @@ program
384
384
  }
385
385
  });
386
386
 
387
+ // === SESSION-RECAP ===
388
+ program
389
+ .command('session-recap <sessionKey>')
390
+ .description('Generate recap from a specific OpenClaw session transcript')
391
+ .option('-n, --limit <n>', 'Number of messages to include', '15')
392
+ .option('--format <format>', 'Output format (markdown|json)', 'markdown')
393
+ .option('-a, --agent <id>', 'Agent ID (default: OPENCLAW_AGENT_ID or clawdious)')
394
+ .action(async (sessionKey, options) => {
395
+ try {
396
+ const { sessionRecapCommand } = await import('../dist/commands/session-recap.js');
397
+ const format = options.format === 'json' ? 'json' : 'markdown';
398
+ const parsedLimit = Number.parseInt(options.limit, 10);
399
+ await sessionRecapCommand(sessionKey, {
400
+ limit: Number.isNaN(parsedLimit) ? 15 : parsedLimit,
401
+ format,
402
+ agentId: options.agent
403
+ });
404
+ } catch (err) {
405
+ console.error(chalk.red(`Error: ${err.message}`));
406
+ process.exit(1);
407
+ }
408
+ });
409
+
387
410
  // === LIST ===
388
411
  program
389
412
  .command('list [category]')
@@ -0,0 +1,273 @@
1
+ import {
2
+ getSessionFilePath,
3
+ getSessionsDir,
4
+ loadSessionsStore
5
+ } from "./chunk-AZRV2I5U.js";
6
+
7
+ // src/commands/session-recap.ts
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ var DEFAULT_LIMIT = 15;
11
+ var MAX_LIMIT = 50;
12
+ var READ_CHUNK_SIZE = 64 * 1024;
13
+ var MAX_TURN_TEXT_LENGTH = 420;
14
+ var MAX_TOTAL_TEXT_LENGTH = 12e3;
15
+ var SESSION_KEY_PATTERN = /^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/;
16
+ var AGENT_ID_PATTERN = /^[a-zA-Z0-9_-]{1,100}$/;
17
+ function sanitizeSessionKey(input) {
18
+ const sessionKey = input.trim();
19
+ if (!SESSION_KEY_PATTERN.test(sessionKey)) {
20
+ throw new Error("Invalid session key. Expected format: agent:<agentId>:<scope>");
21
+ }
22
+ return sessionKey;
23
+ }
24
+ function sanitizeAgentId(input) {
25
+ const agentId = input.trim();
26
+ if (!AGENT_ID_PATTERN.test(agentId)) {
27
+ throw new Error('Invalid agent ID. Use letters, numbers, "_" or "-".');
28
+ }
29
+ return agentId;
30
+ }
31
+ function parseAgentIdFromSessionKey(sessionKey) {
32
+ const match = /^agent:([^:]+):/.exec(sessionKey);
33
+ if (!match?.[1]) return null;
34
+ return sanitizeAgentId(match[1]);
35
+ }
36
+ function resolveAgentId(sessionKey, explicitAgentId) {
37
+ if (explicitAgentId) {
38
+ return sanitizeAgentId(explicitAgentId);
39
+ }
40
+ const fromSessionKey = parseAgentIdFromSessionKey(sessionKey);
41
+ if (fromSessionKey) return fromSessionKey;
42
+ const fromEnv = process.env.OPENCLAW_AGENT_ID;
43
+ if (fromEnv) return sanitizeAgentId(fromEnv);
44
+ return "clawdious";
45
+ }
46
+ function normalizeLimit(limit) {
47
+ if (limit === void 0 || Number.isNaN(limit)) return DEFAULT_LIMIT;
48
+ const parsed = Math.floor(limit);
49
+ return Math.min(MAX_LIMIT, Math.max(1, parsed));
50
+ }
51
+ function isPathInside(parentPath, candidatePath) {
52
+ const normalizedParent = parentPath.endsWith(path.sep) ? parentPath : `${parentPath}${path.sep}`;
53
+ return candidatePath.startsWith(normalizedParent);
54
+ }
55
+ function resolveSafeTranscriptPath(agentId, sessionId, sessionFile) {
56
+ const sessionsDir = getSessionsDir(agentId);
57
+ if (!fs.existsSync(sessionsDir)) {
58
+ throw new Error(`Sessions directory not found for agent "${agentId}".`);
59
+ }
60
+ const sessionsDirRealPath = fs.realpathSync(sessionsDir);
61
+ const candidatePaths = [];
62
+ if (typeof sessionFile === "string" && sessionFile.trim()) {
63
+ candidatePaths.push(path.resolve(sessionFile));
64
+ }
65
+ candidatePaths.push(getSessionFilePath(agentId, sessionId));
66
+ for (const candidatePath of candidatePaths) {
67
+ if (path.extname(candidatePath).toLowerCase() !== ".jsonl") continue;
68
+ if (!fs.existsSync(candidatePath)) continue;
69
+ let candidateRealPath = "";
70
+ try {
71
+ candidateRealPath = fs.realpathSync(candidatePath);
72
+ } catch {
73
+ continue;
74
+ }
75
+ if (!isPathInside(sessionsDirRealPath, candidateRealPath)) {
76
+ continue;
77
+ }
78
+ const stat = fs.statSync(candidateRealPath);
79
+ if (!stat.isFile()) continue;
80
+ return candidateRealPath;
81
+ }
82
+ throw new Error(`No valid transcript found for session "${sessionId}".`);
83
+ }
84
+ function getSessionStoreEntry(agentId, sessionKey) {
85
+ const store = loadSessionsStore(agentId);
86
+ if (!store) {
87
+ throw new Error(`Could not load sessions store for agent "${agentId}".`);
88
+ }
89
+ const entry = store[sessionKey];
90
+ if (!entry) {
91
+ throw new Error(`Session key not found: ${sessionKey}`);
92
+ }
93
+ if (typeof entry.sessionId !== "string" || !entry.sessionId.trim()) {
94
+ throw new Error(`Invalid session mapping for "${sessionKey}" (missing sessionId).`);
95
+ }
96
+ return entry;
97
+ }
98
+ function sanitizeText(input) {
99
+ return input.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
100
+ }
101
+ function truncateText(input, maxLength) {
102
+ if (input.length <= maxLength) return input;
103
+ return `${input.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
104
+ }
105
+ function extractTextFromContent(content) {
106
+ if (typeof content === "string") {
107
+ const cleaned = sanitizeText(content);
108
+ return truncateText(cleaned, MAX_TURN_TEXT_LENGTH);
109
+ }
110
+ if (Array.isArray(content)) {
111
+ const parts = [];
112
+ for (const part of content) {
113
+ if (typeof part === "string") {
114
+ const cleaned2 = sanitizeText(part);
115
+ if (cleaned2) parts.push(cleaned2);
116
+ continue;
117
+ }
118
+ if (!part || typeof part !== "object") continue;
119
+ const block = part;
120
+ const blockType = typeof block.type === "string" ? block.type.toLowerCase() : "";
121
+ if (blockType.includes("tool") || blockType.includes("thinking") || blockType.includes("reason")) {
122
+ continue;
123
+ }
124
+ const blockText = typeof block.text === "string" ? block.text : typeof block.content === "string" && blockType.includes("text") ? block.content : "";
125
+ const cleaned = sanitizeText(blockText);
126
+ if (cleaned) parts.push(cleaned);
127
+ }
128
+ return truncateText(parts.join(" "), MAX_TURN_TEXT_LENGTH);
129
+ }
130
+ return "";
131
+ }
132
+ function parseTurnFromLine(line) {
133
+ const trimmed = line.trim();
134
+ if (!trimmed) return null;
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(trimmed);
138
+ } catch {
139
+ return null;
140
+ }
141
+ if (!parsed || typeof parsed !== "object") return null;
142
+ const entry = parsed;
143
+ if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") {
144
+ return null;
145
+ }
146
+ const message = entry.message;
147
+ const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
148
+ if (role !== "user" && role !== "assistant") return null;
149
+ const text = extractTextFromContent(message.content);
150
+ if (!text) return null;
151
+ return { role, text };
152
+ }
153
+ function applyOutputBudget(turns) {
154
+ let remaining = MAX_TOTAL_TEXT_LENGTH;
155
+ const selected = [];
156
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
157
+ if (remaining <= 0) break;
158
+ const current = turns[i];
159
+ let text = current.text;
160
+ if (text.length > remaining) {
161
+ if (remaining < 16) break;
162
+ text = truncateText(text, remaining);
163
+ }
164
+ selected.push({ role: current.role, text });
165
+ remaining -= text.length;
166
+ }
167
+ return selected.reverse();
168
+ }
169
+ function readRecentTurnsFromTranscript(filePath, limit) {
170
+ if (limit <= 0) return [];
171
+ const fileHandle = fs.openSync(filePath, "r");
172
+ const collected = [];
173
+ let remainder = "";
174
+ try {
175
+ let position = fs.fstatSync(fileHandle).size;
176
+ while (position > 0 && collected.length < limit) {
177
+ const readSize = Math.min(READ_CHUNK_SIZE, position);
178
+ position -= readSize;
179
+ const buffer = Buffer.allocUnsafe(readSize);
180
+ fs.readSync(fileHandle, buffer, 0, readSize, position);
181
+ const chunk = buffer.toString("utf-8");
182
+ const text = chunk + remainder;
183
+ const lines = text.split("\n");
184
+ remainder = lines.shift() ?? "";
185
+ for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex -= 1) {
186
+ if (collected.length >= limit) break;
187
+ const turn = parseTurnFromLine(lines[lineIndex]);
188
+ if (turn) collected.push(turn);
189
+ }
190
+ }
191
+ if (position === 0 && remainder && collected.length < limit) {
192
+ const turn = parseTurnFromLine(remainder);
193
+ if (turn) collected.push(turn);
194
+ }
195
+ } finally {
196
+ fs.closeSync(fileHandle);
197
+ }
198
+ return applyOutputBudget(collected.reverse());
199
+ }
200
+ function toSessionLabel(sessionKey, agentId) {
201
+ const normalizedPrefix = `agent:${agentId}:`;
202
+ if (sessionKey.startsWith(normalizedPrefix)) {
203
+ return sessionKey.slice(normalizedPrefix.length) || sessionKey;
204
+ }
205
+ const parts = sessionKey.split(":");
206
+ if (parts[0] === "agent" && parts.length > 2) {
207
+ return parts.slice(2).join(":");
208
+ }
209
+ return sessionKey;
210
+ }
211
+ function formatSessionRecapMarkdown(result) {
212
+ let output = `## Session Recap: ${result.sessionLabel}
213
+
214
+ `;
215
+ output += `### Recent Conversation (last ${result.count} messages)
216
+ `;
217
+ if (result.messages.length === 0) {
218
+ output += "_No recent user/assistant messages found._\n";
219
+ return output.trimEnd();
220
+ }
221
+ for (const message of result.messages) {
222
+ const label = message.role === "user" ? "User" : "Assistant";
223
+ output += `**${label}:** ${message.text}
224
+
225
+ `;
226
+ }
227
+ return output.trimEnd();
228
+ }
229
+ async function buildSessionRecap(sessionKeyInput, options = {}) {
230
+ const sessionKey = sanitizeSessionKey(sessionKeyInput);
231
+ const agentId = resolveAgentId(sessionKey, options.agentId);
232
+ const limit = normalizeLimit(options.limit);
233
+ const entry = getSessionStoreEntry(agentId, sessionKey);
234
+ const sessionId = String(entry.sessionId).trim();
235
+ const transcriptPath = resolveSafeTranscriptPath(agentId, sessionId, entry.sessionFile);
236
+ const messages = readRecentTurnsFromTranscript(transcriptPath, limit);
237
+ const result = {
238
+ sessionKey,
239
+ sessionLabel: toSessionLabel(sessionKey, agentId),
240
+ agentId,
241
+ sessionId,
242
+ transcriptPath,
243
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
244
+ count: messages.length,
245
+ messages,
246
+ markdown: ""
247
+ };
248
+ result.markdown = formatSessionRecapMarkdown(result);
249
+ return result;
250
+ }
251
+ async function sessionRecapCommand(sessionKey, options = {}) {
252
+ const result = await buildSessionRecap(sessionKey, options);
253
+ const format = options.format ?? "markdown";
254
+ if (format === "json") {
255
+ console.log(JSON.stringify({
256
+ sessionKey: result.sessionKey,
257
+ sessionLabel: result.sessionLabel,
258
+ agentId: result.agentId,
259
+ sessionId: result.sessionId,
260
+ generated: result.generated,
261
+ count: result.count,
262
+ messages: result.messages
263
+ }, null, 2));
264
+ return;
265
+ }
266
+ console.log(result.markdown);
267
+ }
268
+
269
+ export {
270
+ formatSessionRecapMarkdown,
271
+ buildSessionRecap,
272
+ sessionRecapCommand
273
+ };
@@ -0,0 +1,27 @@
1
+ type SessionRecapFormat = 'markdown' | 'json';
2
+ type SessionRole = 'user' | 'assistant';
3
+ interface SessionRecapOptions {
4
+ limit?: number;
5
+ format?: SessionRecapFormat;
6
+ agentId?: string;
7
+ }
8
+ interface SessionTurn {
9
+ role: SessionRole;
10
+ text: string;
11
+ }
12
+ interface SessionRecapResult {
13
+ sessionKey: string;
14
+ sessionLabel: string;
15
+ agentId: string;
16
+ sessionId: string;
17
+ transcriptPath: string;
18
+ generated: string;
19
+ count: number;
20
+ messages: SessionTurn[];
21
+ markdown: string;
22
+ }
23
+ declare function formatSessionRecapMarkdown(result: SessionRecapResult): string;
24
+ declare function buildSessionRecap(sessionKeyInput: string, options?: SessionRecapOptions): Promise<SessionRecapResult>;
25
+ declare function sessionRecapCommand(sessionKey: string, options?: SessionRecapOptions): Promise<void>;
26
+
27
+ export { type SessionRecapFormat, type SessionRecapOptions, type SessionRecapResult, type SessionRole, type SessionTurn, buildSessionRecap, formatSessionRecapMarkdown, sessionRecapCommand };
@@ -0,0 +1,11 @@
1
+ import {
2
+ buildSessionRecap,
3
+ formatSessionRecapMarkdown,
4
+ sessionRecapCommand
5
+ } from "../chunk-XQUQIW6E.js";
6
+ import "../chunk-AZRV2I5U.js";
7
+ export {
8
+ buildSessionRecap,
9
+ formatSessionRecapMarkdown,
10
+ sessionRecapCommand
11
+ };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { V as VaultConfig, S as StoreOptions, D as Document, a as SearchOptions,
2
2
  export { f as DEFAULT_CATEGORIES, g as DEFAULT_CONFIG, h as MEMORY_TYPES, T as TYPE_TO_CATEGORY, i as VaultMeta } from './types-DMU3SuAV.js';
3
3
  export { setupCommand } from './commands/setup.js';
4
4
  export { ContextEntry, ContextFormat, ContextOptions, ContextResult, buildContext, contextCommand, formatContextMarkdown } from './commands/context.js';
5
+ export { SessionRecapFormat, SessionRecapOptions, SessionRecapResult, SessionTurn, buildSessionRecap, formatSessionRecapMarkdown, sessionRecapCommand } from './commands/session-recap.js';
5
6
  export { TemplateVariables, buildTemplateVariables, renderTemplate } from './lib/template-engine.js';
6
7
 
7
8
  /**
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ buildSessionRecap,
3
+ formatSessionRecapMarkdown,
4
+ sessionRecapCommand
5
+ } from "./chunk-XQUQIW6E.js";
1
6
  import {
2
7
  setupCommand
3
8
  } from "./chunk-PIJGYMQZ.js";
@@ -30,6 +35,7 @@ import {
30
35
  qmdEmbed,
31
36
  qmdUpdate
32
37
  } from "./chunk-MIIXBNO3.js";
38
+ import "./chunk-AZRV2I5U.js";
33
39
 
34
40
  // src/index.ts
35
41
  import * as fs from "fs";
@@ -55,6 +61,7 @@ export {
55
61
  TYPE_TO_CATEGORY,
56
62
  VERSION,
57
63
  buildContext,
64
+ buildSessionRecap,
58
65
  buildTemplateVariables,
59
66
  contextCommand,
60
67
  createVault,
@@ -62,9 +69,11 @@ export {
62
69
  extractWikiLinks,
63
70
  findVault,
64
71
  formatContextMarkdown,
72
+ formatSessionRecapMarkdown,
65
73
  hasQmd,
66
74
  qmdEmbed,
67
75
  qmdUpdate,
68
76
  renderTemplate,
77
+ sessionRecapCommand,
69
78
  setupCommand
70
79
  };
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: clawvault
3
- description: "Context death resilience - auto-checkpoint and recovery detection"
3
+ description: "Context resilience - recovery detection, auto-checkpoint, and session context injection"
4
4
  metadata:
5
5
  openclaw:
6
6
  emoji: "🐘"
7
- events: ["gateway:startup", "command:new"]
7
+ events: ["gateway:startup", "command:new", "session:start"]
8
8
  requires:
9
9
  bins: ["clawvault"]
10
10
  ---
@@ -15,6 +15,7 @@ Integrates ClawVault's context death resilience into OpenClaw:
15
15
 
16
16
  - **On gateway startup**: Checks for context death, alerts agent
17
17
  - **On /new command**: Auto-checkpoints before session reset
18
+ - **On session start**: Injects relevant vault context for the initial prompt
18
19
 
19
20
  ## Installation
20
21
 
@@ -43,6 +44,20 @@ openclaw hooks enable clawvault
43
44
  2. Captures state even if agent forgot to handoff
44
45
  3. Ensures continuity across session resets
45
46
 
47
+ ### Session Start
48
+
49
+ 1. Extracts the initial user prompt (`context.initialPrompt` or first user message)
50
+ 2. Runs `clawvault context "<prompt>" --format json -v <vaultPath>`
51
+ 3. Injects up to 4 relevant context bullets into session messages
52
+
53
+ Injection format:
54
+
55
+ ```text
56
+ [ClawVault] Relevant context for this task:
57
+ - <title> (<age>): <snippet>
58
+ - <title> (<age>): <snippet>
59
+ ```
60
+
46
61
  ## No Configuration Needed
47
62
 
48
63
  Just enable the hook. It auto-detects vault path via:
@@ -4,6 +4,7 @@
4
4
  * Provides automatic context death resilience:
5
5
  * - gateway:startup → detect context death, inject recovery info
6
6
  * - command:new → auto-checkpoint before session reset
7
+ * - session:start → inject relevant context for first user prompt
7
8
  *
8
9
  * SECURITY: Uses execFileSync (no shell) to prevent command injection
9
10
  */
@@ -12,6 +13,12 @@ import { execFileSync } from 'child_process';
12
13
  import * as fs from 'fs';
13
14
  import * as path from 'path';
14
15
 
16
+ const MAX_CONTEXT_RESULTS = 4;
17
+ const MAX_CONTEXT_PROMPT_LENGTH = 500;
18
+ const MAX_CONTEXT_SNIPPET_LENGTH = 220;
19
+ const MAX_RECAP_RESULTS = 6;
20
+ const MAX_RECAP_SNIPPET_LENGTH = 220;
21
+
15
22
  // Sanitize string for safe display (prevent prompt injection via control chars)
16
23
  function sanitizeForDisplay(str) {
17
24
  if (typeof str !== 'string') return '';
@@ -22,6 +29,205 @@ function sanitizeForDisplay(str) {
22
29
  .slice(0, 200); // Limit length
23
30
  }
24
31
 
32
+ // Sanitize prompt before passing to CLI command
33
+ function sanitizePromptForContext(str) {
34
+ if (typeof str !== 'string') return '';
35
+ return str
36
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ .slice(0, MAX_CONTEXT_PROMPT_LENGTH);
40
+ }
41
+
42
+ function sanitizeSessionKey(str) {
43
+ if (typeof str !== 'string') return '';
44
+ const trimmed = str.trim();
45
+ if (!/^agent:[a-zA-Z0-9_-]+:[a-zA-Z0-9:_-]+$/.test(trimmed)) {
46
+ return '';
47
+ }
48
+ return trimmed.slice(0, 200);
49
+ }
50
+
51
+ function extractSessionKey(event) {
52
+ const candidates = [
53
+ event?.sessionKey,
54
+ event?.context?.sessionKey,
55
+ event?.session?.key,
56
+ event?.context?.session?.key,
57
+ event?.metadata?.sessionKey
58
+ ];
59
+
60
+ for (const candidate of candidates) {
61
+ const key = sanitizeSessionKey(candidate);
62
+ if (key) return key;
63
+ }
64
+
65
+ return '';
66
+ }
67
+
68
+ function extractAgentIdFromSessionKey(sessionKey) {
69
+ const match = /^agent:([^:]+):/.exec(sessionKey);
70
+ if (!match?.[1]) return '';
71
+ const agentId = match[1].trim();
72
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentId)) return '';
73
+ return agentId;
74
+ }
75
+
76
+ function extractTextFromMessage(message) {
77
+ if (typeof message === 'string') return message;
78
+ if (!message || typeof message !== 'object') return '';
79
+
80
+ const content = message.content ?? message.text ?? message.message;
81
+ if (typeof content === 'string') return content;
82
+
83
+ if (Array.isArray(content)) {
84
+ return content
85
+ .map((part) => {
86
+ if (typeof part === 'string') return part;
87
+ if (!part || typeof part !== 'object') return '';
88
+ if (typeof part.text === 'string') return part.text;
89
+ if (typeof part.content === 'string') return part.content;
90
+ return '';
91
+ })
92
+ .filter(Boolean)
93
+ .join(' ');
94
+ }
95
+
96
+ return '';
97
+ }
98
+
99
+ function isUserMessage(message) {
100
+ if (typeof message === 'string') return true;
101
+ if (!message || typeof message !== 'object') return false;
102
+ const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
103
+ const type = typeof message.type === 'string' ? message.type.toLowerCase() : '';
104
+ return role === 'user' || role === 'human' || type === 'user';
105
+ }
106
+
107
+ function extractInitialPrompt(event) {
108
+ const fromContext = sanitizePromptForContext(event?.context?.initialPrompt);
109
+ if (fromContext) return fromContext;
110
+
111
+ const candidates = [
112
+ event?.context?.messages,
113
+ event?.context?.initialMessages,
114
+ event?.context?.history,
115
+ event?.messages
116
+ ];
117
+
118
+ for (const list of candidates) {
119
+ if (!Array.isArray(list)) continue;
120
+ for (const message of list) {
121
+ if (!isUserMessage(message)) continue;
122
+ const text = sanitizePromptForContext(extractTextFromMessage(message));
123
+ if (text) return text;
124
+ }
125
+ }
126
+
127
+ return '';
128
+ }
129
+
130
+ function truncateSnippet(snippet) {
131
+ const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
132
+ if (safe.length <= MAX_CONTEXT_SNIPPET_LENGTH) return safe;
133
+ return `${safe.slice(0, MAX_CONTEXT_SNIPPET_LENGTH - 3).trimEnd()}...`;
134
+ }
135
+
136
+ function truncateRecapSnippet(snippet) {
137
+ const safe = sanitizeForDisplay(snippet).replace(/\s+/g, ' ').trim();
138
+ if (safe.length <= MAX_RECAP_SNIPPET_LENGTH) return safe;
139
+ return `${safe.slice(0, MAX_RECAP_SNIPPET_LENGTH - 3).trimEnd()}...`;
140
+ }
141
+
142
+ function parseContextJson(output) {
143
+ try {
144
+ const parsed = JSON.parse(output);
145
+ if (!parsed || !Array.isArray(parsed.context)) return [];
146
+
147
+ return parsed.context
148
+ .slice(0, MAX_CONTEXT_RESULTS)
149
+ .map((entry) => ({
150
+ title: sanitizeForDisplay(entry?.title || 'Untitled'),
151
+ age: sanitizeForDisplay(entry?.age || 'unknown age'),
152
+ snippet: truncateSnippet(entry?.snippet || '')
153
+ }))
154
+ .filter((entry) => entry.snippet);
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ function parseSessionRecapJson(output) {
161
+ try {
162
+ const parsed = JSON.parse(output);
163
+ if (!parsed || !Array.isArray(parsed.messages)) return [];
164
+
165
+ return parsed.messages
166
+ .map((entry) => {
167
+ if (!entry || typeof entry !== 'object') return null;
168
+ const role = typeof entry.role === 'string' ? entry.role.toLowerCase() : '';
169
+ if (role !== 'user' && role !== 'assistant') return null;
170
+ const text = truncateRecapSnippet(typeof entry.text === 'string' ? entry.text : '');
171
+ if (!text) return null;
172
+ return {
173
+ role: role === 'user' ? 'User' : 'Assistant',
174
+ text
175
+ };
176
+ })
177
+ .filter(Boolean)
178
+ .slice(-MAX_RECAP_RESULTS);
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ function formatSessionContextInjection(recapEntries, memoryEntries) {
185
+ const lines = ['[ClawVault] Session context restored:', '', 'Recent conversation:'];
186
+
187
+ if (recapEntries.length === 0) {
188
+ lines.push('- No recent user/assistant turns found for this session.');
189
+ } else {
190
+ for (const entry of recapEntries) {
191
+ lines.push(`- ${entry.role}: ${entry.text}`);
192
+ }
193
+ }
194
+
195
+ lines.push('', 'Relevant memories:');
196
+ if (memoryEntries.length === 0) {
197
+ lines.push('- No relevant vault memories found for the current prompt.');
198
+ } else {
199
+ for (const entry of memoryEntries) {
200
+ lines.push(`- ${entry.title} (${entry.age}): ${entry.snippet}`);
201
+ }
202
+ }
203
+
204
+ return lines.join('\n');
205
+ }
206
+
207
+ function injectSystemMessage(event, message) {
208
+ if (!event.messages || !Array.isArray(event.messages)) return false;
209
+
210
+ if (event.messages.length === 0) {
211
+ event.messages.push(message);
212
+ return true;
213
+ }
214
+
215
+ const first = event.messages[0];
216
+ if (first && typeof first === 'object' && !Array.isArray(first)) {
217
+ if ('role' in first || 'content' in first) {
218
+ event.messages.push({ role: 'system', content: message });
219
+ return true;
220
+ }
221
+ if ('type' in first || 'text' in first) {
222
+ event.messages.push({ type: 'system', text: message });
223
+ return true;
224
+ }
225
+ }
226
+
227
+ event.messages.push(message);
228
+ return true;
229
+ }
230
+
25
231
  // Validate vault path - must be absolute and exist
26
232
  function validateVaultPath(vaultPath) {
27
233
  if (!vaultPath || typeof vaultPath !== 'string') return null;
@@ -151,11 +357,9 @@ async function handleStartup(event) {
151
357
  const alertMsg = alertParts.join(' ');
152
358
 
153
359
  // Inject into event messages if available
154
- if (event.messages && Array.isArray(event.messages)) {
155
- event.messages.push(alertMsg);
360
+ if (injectSystemMessage(event, alertMsg)) {
361
+ console.warn('[clawvault] Context death detected, alert injected');
156
362
  }
157
-
158
- console.warn('[clawvault] Context death detected, alert injected');
159
363
  } else {
160
364
  console.log('[clawvault] Clean startup - no context death');
161
365
  }
@@ -194,6 +398,67 @@ async function handleNew(event) {
194
398
  }
195
399
  }
196
400
 
401
+ // Handle session start - inject dynamic context for first prompt
402
+ async function handleSessionStart(event) {
403
+ const vaultPath = findVaultPath();
404
+ if (!vaultPath) {
405
+ console.log('[clawvault] No vault found, skipping context injection');
406
+ return;
407
+ }
408
+
409
+ const sessionKey = extractSessionKey(event);
410
+ const prompt = extractInitialPrompt(event);
411
+ let recapEntries = [];
412
+ let memoryEntries = [];
413
+
414
+ if (sessionKey) {
415
+ console.log('[clawvault] Fetching session recap for context restoration');
416
+ const recapArgs = ['session-recap', sessionKey, '--format', 'json'];
417
+ const agentId = extractAgentIdFromSessionKey(sessionKey);
418
+ if (agentId) {
419
+ recapArgs.push('--agent', agentId);
420
+ }
421
+
422
+ const recapResult = runClawvault(recapArgs);
423
+ if (!recapResult.success) {
424
+ console.warn('[clawvault] Session recap lookup failed');
425
+ } else {
426
+ recapEntries = parseSessionRecapJson(recapResult.output);
427
+ }
428
+ } else {
429
+ console.log('[clawvault] No session key found, skipping session recap');
430
+ }
431
+
432
+ if (prompt) {
433
+ console.log('[clawvault] Fetching vault memories for session start prompt');
434
+ const contextResult = runClawvault([
435
+ 'context',
436
+ prompt,
437
+ '--format', 'json',
438
+ '-v', vaultPath
439
+ ]);
440
+
441
+ if (!contextResult.success) {
442
+ console.warn('[clawvault] Context lookup failed');
443
+ } else {
444
+ memoryEntries = parseContextJson(contextResult.output);
445
+ }
446
+ } else {
447
+ console.log('[clawvault] No initial prompt, skipping vault memory lookup');
448
+ }
449
+
450
+ if (recapEntries.length === 0 && memoryEntries.length === 0) {
451
+ console.log('[clawvault] No session context available to inject');
452
+ return;
453
+ }
454
+
455
+ if (injectSystemMessage(event, formatSessionContextInjection(recapEntries, memoryEntries))) {
456
+ console.log(`[clawvault] Injected session context (${recapEntries.length} recap, ${memoryEntries.length} memories)`);
457
+ } else {
458
+ console.log('[clawvault] No message array available, skipping injection');
459
+ }
460
+ }
461
+
197
462
  // Main handler - route events
198
463
  const handler = async (event) => {
199
464
  try {
@@ -206,6 +471,11 @@ const handler = async (event) => {
206
471
  await handleNew(event);
207
472
  return;
208
473
  }
474
+
475
+ if (event.type === 'session' && event.action === 'start') {
476
+ await handleSessionStart(event);
477
+ return;
478
+ }
209
479
  } catch (err) {
210
480
  console.error('[clawvault] Hook error:', err.message || 'unknown error');
211
481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawvault",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "🐘 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",
@@ -28,7 +28,7 @@
28
28
  ]
29
29
  },
30
30
  "scripts": {
31
- "build": "tsup src/index.ts src/commands/entities.ts src/commands/link.ts src/commands/checkpoint.ts src/commands/recover.ts src/commands/status.ts src/commands/template.ts src/commands/setup.ts src/commands/context.ts src/commands/wake.ts src/commands/sleep.ts src/commands/doctor.ts src/commands/shell-init.ts src/commands/repair-session.ts src/lib/entity-index.ts src/lib/auto-linker.ts src/lib/config.ts src/lib/template-engine.ts src/lib/session-utils.ts src/lib/session-repair.ts --format esm --dts --clean",
31
+ "build": "tsup src/index.ts src/commands/entities.ts src/commands/link.ts src/commands/checkpoint.ts src/commands/recover.ts src/commands/status.ts src/commands/template.ts src/commands/setup.ts src/commands/context.ts src/commands/session-recap.ts src/commands/wake.ts src/commands/sleep.ts src/commands/doctor.ts src/commands/shell-init.ts src/commands/repair-session.ts src/lib/entity-index.ts src/lib/auto-linker.ts src/lib/config.ts src/lib/template-engine.ts src/lib/session-utils.ts src/lib/session-repair.ts --format esm --dts --clean",
32
32
  "dev": "tsup src/index.ts src/commands/*.ts src/lib/*.ts --format esm --dts --watch",
33
33
  "lint": "eslint src",
34
34
  "typecheck": "tsc --noEmit",