clawvault 1.4.2 → 1.5.1

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
@@ -1081,5 +1081,53 @@ program
1081
1081
  }
1082
1082
  });
1083
1083
 
1084
+ // === REPAIR-SESSION ===
1085
+ program
1086
+ .command('repair-session')
1087
+ .description('Repair corrupted OpenClaw session transcripts')
1088
+ .option('-s, --session <id>', 'Session ID (defaults to current main session)')
1089
+ .option('-a, --agent <id>', 'Agent ID (defaults to configured agent)')
1090
+ .option('--backup', 'Create backup before repair (default: true)', true)
1091
+ .option('--no-backup', 'Skip backup creation')
1092
+ .option('--dry-run', 'Show what would be repaired without writing')
1093
+ .option('--list', 'List available sessions')
1094
+ .option('--json', 'Output as JSON')
1095
+ .action(async (options) => {
1096
+ try {
1097
+ const {
1098
+ repairSessionCommand,
1099
+ formatRepairResult,
1100
+ listAgentSessions
1101
+ } = await import('../dist/commands/repair-session.js');
1102
+
1103
+ // List mode
1104
+ if (options.list) {
1105
+ console.log(listAgentSessions(options.agent));
1106
+ return;
1107
+ }
1108
+
1109
+ const result = await repairSessionCommand({
1110
+ sessionId: options.session,
1111
+ agentId: options.agent,
1112
+ backup: options.backup,
1113
+ dryRun: options.dryRun
1114
+ });
1115
+
1116
+ if (options.json) {
1117
+ console.log(JSON.stringify(result, null, 2));
1118
+ } else {
1119
+ console.log(formatRepairResult(result, { dryRun: options.dryRun }));
1120
+ }
1121
+
1122
+ // Exit with code 1 if corruption was found but not fixed (dry-run)
1123
+ if (result.corruptedEntries.length > 0 && !result.repaired) {
1124
+ process.exit(1);
1125
+ }
1126
+ } catch (err) {
1127
+ console.error(chalk.red(`Error: ${err.message}`));
1128
+ process.exit(1);
1129
+ }
1130
+ });
1131
+
1084
1132
  // Parse and run
1085
1133
  program.parse();
@@ -0,0 +1,133 @@
1
+ // src/lib/session-utils.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ function getOpenClawAgentsDir() {
6
+ return path.join(os.homedir(), ".openclaw", "agents");
7
+ }
8
+ function getSessionsDir(agentId) {
9
+ return path.join(getOpenClawAgentsDir(), agentId, "sessions");
10
+ }
11
+ function getSessionsJsonPath(agentId) {
12
+ return path.join(getSessionsDir(agentId), "sessions.json");
13
+ }
14
+ function getSessionFilePath(agentId, sessionId) {
15
+ return path.join(getSessionsDir(agentId), `${sessionId}.jsonl`);
16
+ }
17
+ function listAgents() {
18
+ const agentsDir = getOpenClawAgentsDir();
19
+ if (!fs.existsSync(agentsDir)) {
20
+ return [];
21
+ }
22
+ return fs.readdirSync(agentsDir).filter((name) => {
23
+ const sessionsDir = getSessionsDir(name);
24
+ return fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory();
25
+ });
26
+ }
27
+ function loadSessionsStore(agentId) {
28
+ const sessionsJsonPath = getSessionsJsonPath(agentId);
29
+ if (!fs.existsSync(sessionsJsonPath)) {
30
+ return null;
31
+ }
32
+ try {
33
+ const content = fs.readFileSync(sessionsJsonPath, "utf-8");
34
+ return JSON.parse(content);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ function findMainSession(agentId) {
40
+ const store = loadSessionsStore(agentId);
41
+ if (!store) return null;
42
+ const mainKey = `agent:${agentId}:main`;
43
+ const entry = store[mainKey];
44
+ if (entry?.sessionId) {
45
+ const filePath = getSessionFilePath(agentId, entry.sessionId);
46
+ if (fs.existsSync(filePath)) {
47
+ return {
48
+ sessionId: entry.sessionId,
49
+ sessionKey: mainKey,
50
+ agentId,
51
+ filePath,
52
+ updatedAt: entry.updatedAt
53
+ };
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ function findSessionById(agentId, sessionId) {
59
+ const filePath = getSessionFilePath(agentId, sessionId);
60
+ if (!fs.existsSync(filePath)) {
61
+ return null;
62
+ }
63
+ const store = loadSessionsStore(agentId);
64
+ let sessionKey;
65
+ let updatedAt;
66
+ if (store) {
67
+ for (const [key, entry] of Object.entries(store)) {
68
+ if (entry.sessionId === sessionId) {
69
+ sessionKey = key;
70
+ updatedAt = entry.updatedAt;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ return {
76
+ sessionId,
77
+ sessionKey: sessionKey || `agent:${agentId}:unknown`,
78
+ agentId,
79
+ filePath,
80
+ updatedAt
81
+ };
82
+ }
83
+ function listSessions(agentId) {
84
+ const sessionsDir = getSessionsDir(agentId);
85
+ if (!fs.existsSync(sessionsDir)) {
86
+ return [];
87
+ }
88
+ const store = loadSessionsStore(agentId);
89
+ const sessions = [];
90
+ const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl") && !f.includes(".backup") && !f.includes(".deleted") && !f.includes(".corrupted"));
91
+ for (const file of files) {
92
+ const sessionId = file.replace(".jsonl", "");
93
+ const filePath = path.join(sessionsDir, file);
94
+ let sessionKey = `agent:${agentId}:unknown`;
95
+ let updatedAt;
96
+ if (store) {
97
+ for (const [key, entry] of Object.entries(store)) {
98
+ if (entry.sessionId === sessionId) {
99
+ sessionKey = key;
100
+ updatedAt = entry.updatedAt;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ sessions.push({
106
+ sessionId,
107
+ sessionKey,
108
+ agentId,
109
+ filePath,
110
+ updatedAt
111
+ });
112
+ }
113
+ return sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
114
+ }
115
+ function backupSession(filePath) {
116
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15);
117
+ const backupPath = `${filePath}.backup-${timestamp}`;
118
+ fs.copyFileSync(filePath, backupPath);
119
+ return backupPath;
120
+ }
121
+
122
+ export {
123
+ getOpenClawAgentsDir,
124
+ getSessionsDir,
125
+ getSessionsJsonPath,
126
+ getSessionFilePath,
127
+ listAgents,
128
+ loadSessionsStore,
129
+ findMainSession,
130
+ findSessionById,
131
+ listSessions,
132
+ backupSession
133
+ };
@@ -0,0 +1,200 @@
1
+ // src/lib/session-repair.ts
2
+ import * as fs from "fs";
3
+ function parseTranscript(filePath) {
4
+ const content = fs.readFileSync(filePath, "utf-8");
5
+ const lines = content.split("\n").filter((line) => line.trim());
6
+ const entries = [];
7
+ for (let i = 0; i < lines.length; i++) {
8
+ const raw = lines[i];
9
+ try {
10
+ const entry = JSON.parse(raw);
11
+ entries.push({ line: i + 1, entry, raw });
12
+ } catch {
13
+ console.warn(`Warning: Could not parse line ${i + 1}`);
14
+ }
15
+ }
16
+ return entries;
17
+ }
18
+ function extractToolUses(entries) {
19
+ const toolUses = /* @__PURE__ */ new Map();
20
+ for (const { line, entry } of entries) {
21
+ if (entry.type !== "message") continue;
22
+ if (entry.message?.role !== "assistant") continue;
23
+ const isAborted = entry.message.stopReason === "aborted";
24
+ const content = entry.message.content || [];
25
+ for (const block of content) {
26
+ if (block.type === "toolCall" || block.type === "tool_use" || block.type === "functionCall") {
27
+ if (block.id) {
28
+ const isPartial = !!block.partialJson;
29
+ toolUses.set(block.id, {
30
+ id: block.id,
31
+ lineNumber: line,
32
+ entryId: entry.id,
33
+ isAborted: isAborted || isPartial,
34
+ isPartial,
35
+ name: block.name
36
+ });
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return toolUses;
42
+ }
43
+ function findCorruptedEntries(entries, toolUses) {
44
+ const corrupted = [];
45
+ const entriesToRemove = /* @__PURE__ */ new Set();
46
+ for (const [toolId, info] of toolUses) {
47
+ if (info.isAborted) {
48
+ corrupted.push({
49
+ lineNumber: info.lineNumber,
50
+ entryId: info.entryId,
51
+ type: "aborted_tool_use",
52
+ toolUseId: toolId,
53
+ description: `Aborted tool_use${info.name ? ` (${info.name})` : ""} with id: ${toolId}`
54
+ });
55
+ entriesToRemove.add(info.entryId);
56
+ }
57
+ }
58
+ for (const { line, entry } of entries) {
59
+ if (entry.type !== "message") continue;
60
+ if (entry.message?.role !== "toolResult") continue;
61
+ const content = entry.message.content || [];
62
+ let toolCallId;
63
+ const msg = entry.message;
64
+ toolCallId = msg.toolCallId || msg.toolUseId;
65
+ if (!toolCallId) {
66
+ for (const block of content) {
67
+ if (block.toolCallId || block.toolUseId) {
68
+ toolCallId = block.toolCallId || block.toolUseId;
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ if (!toolCallId) continue;
74
+ const toolUse = toolUses.get(toolCallId);
75
+ if (!toolUse || toolUse.isAborted) {
76
+ corrupted.push({
77
+ lineNumber: line,
78
+ entryId: entry.id,
79
+ type: "orphaned_tool_result",
80
+ toolUseId: toolCallId,
81
+ description: toolUse ? `Orphaned tool_result references aborted tool_use: ${toolCallId}` : `Orphaned tool_result references non-existent tool_use: ${toolCallId}`
82
+ });
83
+ entriesToRemove.add(entry.id);
84
+ }
85
+ }
86
+ return { corrupted, entriesToRemove };
87
+ }
88
+ function computeParentRelinks(entries, entriesToRemove) {
89
+ const relinks = [];
90
+ const entryParents = /* @__PURE__ */ new Map();
91
+ for (const { entry } of entries) {
92
+ entryParents.set(entry.id, entry.parentId);
93
+ }
94
+ for (const { line, entry } of entries) {
95
+ if (entriesToRemove.has(entry.id)) continue;
96
+ if (!entry.parentId) continue;
97
+ if (!entriesToRemove.has(entry.parentId)) continue;
98
+ let newParentId = entry.parentId;
99
+ while (newParentId && entriesToRemove.has(newParentId)) {
100
+ newParentId = entryParents.get(newParentId) || null;
101
+ }
102
+ if (newParentId !== entry.parentId) {
103
+ relinks.push({
104
+ lineNumber: line,
105
+ entryId: entry.id,
106
+ oldParentId: entry.parentId,
107
+ newParentId: newParentId || "null"
108
+ });
109
+ }
110
+ }
111
+ return relinks;
112
+ }
113
+ function analyzeSession(filePath) {
114
+ const entries = parseTranscript(filePath);
115
+ const sessionEntry = entries.find((e) => e.entry.type === "session");
116
+ const sessionId = sessionEntry?.entry.id || "unknown";
117
+ const toolUses = extractToolUses(entries);
118
+ const { corrupted, entriesToRemove } = findCorruptedEntries(entries, toolUses);
119
+ const parentRelinks = computeParentRelinks(entries, entriesToRemove);
120
+ return {
121
+ sessionId,
122
+ totalLines: entries.length,
123
+ corruptedEntries: corrupted,
124
+ parentRelinks,
125
+ removedCount: entriesToRemove.size,
126
+ relinkedCount: parentRelinks.length,
127
+ repaired: false
128
+ };
129
+ }
130
+ function repairSession(filePath, options = {}) {
131
+ const { backup = true, dryRun = false } = options;
132
+ const entries = parseTranscript(filePath);
133
+ const sessionEntry = entries.find((e) => e.entry.type === "session");
134
+ const sessionId = sessionEntry?.entry.id || "unknown";
135
+ const toolUses = extractToolUses(entries);
136
+ const { corrupted, entriesToRemove } = findCorruptedEntries(entries, toolUses);
137
+ const parentRelinks = computeParentRelinks(entries, entriesToRemove);
138
+ if (corrupted.length === 0) {
139
+ return {
140
+ sessionId,
141
+ totalLines: entries.length,
142
+ corruptedEntries: [],
143
+ parentRelinks: [],
144
+ removedCount: 0,
145
+ relinkedCount: 0,
146
+ repaired: false
147
+ };
148
+ }
149
+ if (dryRun) {
150
+ return {
151
+ sessionId,
152
+ totalLines: entries.length,
153
+ corruptedEntries: corrupted,
154
+ parentRelinks,
155
+ removedCount: entriesToRemove.size,
156
+ relinkedCount: parentRelinks.length,
157
+ repaired: false
158
+ };
159
+ }
160
+ let backupPath;
161
+ if (backup) {
162
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15);
163
+ backupPath = `${filePath}.backup-${timestamp}`;
164
+ fs.copyFileSync(filePath, backupPath);
165
+ }
166
+ const relinkMap = /* @__PURE__ */ new Map();
167
+ for (const relink of parentRelinks) {
168
+ relinkMap.set(relink.entryId, relink.newParentId === "null" ? null : relink.newParentId);
169
+ }
170
+ const repairedLines = [];
171
+ for (const { entry, raw } of entries) {
172
+ if (entriesToRemove.has(entry.id)) continue;
173
+ if (relinkMap.has(entry.id)) {
174
+ const newEntry = { ...entry, parentId: relinkMap.get(entry.id) };
175
+ repairedLines.push(JSON.stringify(newEntry));
176
+ } else {
177
+ repairedLines.push(raw);
178
+ }
179
+ }
180
+ fs.writeFileSync(filePath, repairedLines.join("\n") + "\n");
181
+ return {
182
+ sessionId,
183
+ totalLines: entries.length,
184
+ corruptedEntries: corrupted,
185
+ parentRelinks,
186
+ removedCount: entriesToRemove.size,
187
+ relinkedCount: parentRelinks.length,
188
+ backupPath,
189
+ repaired: true
190
+ };
191
+ }
192
+
193
+ export {
194
+ parseTranscript,
195
+ extractToolUses,
196
+ findCorruptedEntries,
197
+ computeParentRelinks,
198
+ analyzeSession,
199
+ repairSession
200
+ };
@@ -0,0 +1,38 @@
1
+ import { SessionInfo } from '../lib/session-utils.js';
2
+ import { RepairResult } from '../lib/session-repair.js';
3
+
4
+ /**
5
+ * repair-session command - Repair corrupted OpenClaw session transcripts
6
+ *
7
+ * Fixes issues like:
8
+ * - Aborted tool calls with partial JSON
9
+ * - Orphaned tool_result messages referencing non-existent tool_use IDs
10
+ * - Broken parent chain references
11
+ */
12
+
13
+ interface RepairSessionOptions {
14
+ sessionId?: string;
15
+ agentId?: string;
16
+ backup?: boolean;
17
+ dryRun?: boolean;
18
+ }
19
+ /**
20
+ * Resolve the session to repair
21
+ */
22
+ declare function resolveSession(options: RepairSessionOptions): SessionInfo | null;
23
+ /**
24
+ * Format repair result for CLI output
25
+ */
26
+ declare function formatRepairResult(result: RepairResult, options?: {
27
+ dryRun?: boolean;
28
+ }): string;
29
+ /**
30
+ * Main repair-session command handler
31
+ */
32
+ declare function repairSessionCommand(options: RepairSessionOptions): Promise<RepairResult>;
33
+ /**
34
+ * List available sessions for an agent (for --list flag)
35
+ */
36
+ declare function listAgentSessions(agentId?: string): string;
37
+
38
+ export { type RepairSessionOptions, formatRepairResult, listAgentSessions, repairSessionCommand, resolveSession };
@@ -0,0 +1,121 @@
1
+ import {
2
+ analyzeSession,
3
+ repairSession
4
+ } from "../chunk-L53L5FCL.js";
5
+ import {
6
+ findMainSession,
7
+ findSessionById,
8
+ listAgents
9
+ } from "../chunk-AZRV2I5U.js";
10
+
11
+ // src/commands/repair-session.ts
12
+ import * as fs from "fs";
13
+ function resolveSession(options) {
14
+ const { sessionId, agentId } = options;
15
+ if (sessionId && agentId) {
16
+ return findSessionById(agentId, sessionId);
17
+ }
18
+ if (sessionId) {
19
+ const agents = listAgents();
20
+ for (const agent of agents) {
21
+ const session = findSessionById(agent, sessionId);
22
+ if (session) return session;
23
+ }
24
+ return null;
25
+ }
26
+ if (agentId) {
27
+ return findMainSession(agentId);
28
+ }
29
+ const defaultAgent = process.env.OPENCLAW_AGENT_ID || "clawdious";
30
+ return findMainSession(defaultAgent);
31
+ }
32
+ function formatRepairResult(result, options = {}) {
33
+ const { dryRun = false } = options;
34
+ const lines = [];
35
+ lines.push(`Analyzing session: ${result.sessionId}`);
36
+ lines.push("");
37
+ if (result.corruptedEntries.length === 0) {
38
+ lines.push("\u2705 No corruption detected. Session is clean.");
39
+ return lines.join("\n");
40
+ }
41
+ if (dryRun) {
42
+ lines.push(`Found ${result.corruptedEntries.length} corrupted entries:`);
43
+ } else {
44
+ lines.push(`Found and fixed ${result.corruptedEntries.length} corrupted entries:`);
45
+ }
46
+ for (const entry of result.corruptedEntries) {
47
+ const prefix = entry.type === "aborted_tool_use" ? "Aborted tool_use" : "Orphaned tool_result";
48
+ lines.push(` - Line ${entry.lineNumber}: ${prefix} (id: ${entry.toolUseId})`);
49
+ }
50
+ if (result.parentRelinks.length > 0) {
51
+ lines.push("");
52
+ if (dryRun) {
53
+ lines.push(`Would relink ${result.parentRelinks.length} parent reference(s):`);
54
+ } else {
55
+ lines.push(`Relinked ${result.parentRelinks.length} parent reference(s):`);
56
+ }
57
+ for (const relink of result.parentRelinks.slice(0, 5)) {
58
+ lines.push(` - Line ${relink.lineNumber}: parentId ${relink.oldParentId.slice(0, 8)}\u2026 \u2192 ${relink.newParentId === "null" ? "null" : relink.newParentId.slice(0, 8)}\u2026`);
59
+ }
60
+ if (result.parentRelinks.length > 5) {
61
+ lines.push(` ... and ${result.parentRelinks.length - 5} more`);
62
+ }
63
+ }
64
+ lines.push("");
65
+ if (dryRun) {
66
+ lines.push(`Would remove ${result.removedCount} entries, relink ${result.relinkedCount} parent references.`);
67
+ } else {
68
+ lines.push(`\u2705 Session repaired: removed ${result.removedCount} entries, relinked ${result.relinkedCount} parent references`);
69
+ if (result.backupPath) {
70
+ const backupName = result.backupPath.split("/").pop();
71
+ lines.push(`Backup saved: ${backupName}`);
72
+ }
73
+ }
74
+ return lines.join("\n");
75
+ }
76
+ async function repairSessionCommand(options) {
77
+ const { backup = true, dryRun = false } = options;
78
+ const session = resolveSession(options);
79
+ if (!session) {
80
+ throw new Error(
81
+ options.sessionId ? `Session not found: ${options.sessionId}` : options.agentId ? `No main session found for agent: ${options.agentId}` : "No session found. Specify --session or --agent."
82
+ );
83
+ }
84
+ if (!fs.existsSync(session.filePath)) {
85
+ throw new Error(`Session file not found: ${session.filePath}`);
86
+ }
87
+ if (dryRun) {
88
+ return analyzeSession(session.filePath);
89
+ }
90
+ return repairSession(session.filePath, { backup, dryRun: false });
91
+ }
92
+ function listAgentSessions(agentId) {
93
+ const agents = agentId ? [agentId] : listAgents();
94
+ const lines = [];
95
+ if (agents.length === 0) {
96
+ return "No agents found in ~/.openclaw/agents/";
97
+ }
98
+ for (const agent of agents) {
99
+ const mainSession = findMainSession(agent);
100
+ if (mainSession) {
101
+ lines.push(`${agent}:`);
102
+ lines.push(` Main session: ${mainSession.sessionId}`);
103
+ lines.push(` File: ${mainSession.filePath}`);
104
+ if (mainSession.updatedAt) {
105
+ const date = new Date(mainSession.updatedAt);
106
+ lines.push(` Updated: ${date.toISOString()}`);
107
+ }
108
+ lines.push("");
109
+ }
110
+ }
111
+ if (lines.length === 0) {
112
+ return agentId ? `No sessions found for agent: ${agentId}` : "No sessions found.";
113
+ }
114
+ return lines.join("\n");
115
+ }
116
+ export {
117
+ formatRepairResult,
118
+ listAgentSessions,
119
+ repairSessionCommand,
120
+ resolveSession
121
+ };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Session transcript repair logic
3
+ *
4
+ * Repairs corrupted OpenClaw session transcripts by:
5
+ * 1. Finding aborted tool_use blocks (stopReason: "aborted", partialJson present)
6
+ * 2. Finding orphaned tool_result messages that reference non-existent tool_use IDs
7
+ * 3. Removing both the aborted entries and orphaned results
8
+ * 4. Relinking parent chain references
9
+ */
10
+ interface TranscriptEntry {
11
+ type: 'session' | 'message' | 'compaction' | 'custom' | 'thinking_level_change' | string;
12
+ id: string;
13
+ parentId: string | null;
14
+ timestamp: string;
15
+ message?: {
16
+ role: 'user' | 'assistant' | 'toolResult' | 'system';
17
+ content: Array<{
18
+ type: string;
19
+ id?: string;
20
+ name?: string;
21
+ arguments?: unknown;
22
+ toolCallId?: string;
23
+ toolUseId?: string;
24
+ partialJson?: string;
25
+ text?: string;
26
+ }>;
27
+ stopReason?: string;
28
+ errorMessage?: string;
29
+ };
30
+ summary?: string;
31
+ customType?: string;
32
+ data?: unknown;
33
+ thinkingLevel?: string;
34
+ }
35
+ interface ToolUseInfo {
36
+ id: string;
37
+ lineNumber: number;
38
+ entryId: string;
39
+ isAborted: boolean;
40
+ isPartial: boolean;
41
+ name?: string;
42
+ }
43
+ interface CorruptedEntry {
44
+ lineNumber: number;
45
+ entryId: string;
46
+ type: 'aborted_tool_use' | 'orphaned_tool_result';
47
+ toolUseId: string;
48
+ description: string;
49
+ }
50
+ interface ParentRelink {
51
+ lineNumber: number;
52
+ entryId: string;
53
+ oldParentId: string;
54
+ newParentId: string;
55
+ }
56
+ interface RepairResult {
57
+ sessionId: string;
58
+ totalLines: number;
59
+ corruptedEntries: CorruptedEntry[];
60
+ parentRelinks: ParentRelink[];
61
+ removedCount: number;
62
+ relinkedCount: number;
63
+ backupPath?: string;
64
+ repaired: boolean;
65
+ }
66
+ /**
67
+ * Parse a JSONL file into transcript entries with line numbers
68
+ */
69
+ declare function parseTranscript(filePath: string): Array<{
70
+ line: number;
71
+ entry: TranscriptEntry;
72
+ raw: string;
73
+ }>;
74
+ /**
75
+ * Extract all tool_use IDs from assistant messages
76
+ */
77
+ declare function extractToolUses(entries: Array<{
78
+ line: number;
79
+ entry: TranscriptEntry;
80
+ }>): Map<string, ToolUseInfo>;
81
+ /**
82
+ * Find orphaned tool_result messages that reference non-existent or aborted tool_use IDs
83
+ */
84
+ declare function findCorruptedEntries(entries: Array<{
85
+ line: number;
86
+ entry: TranscriptEntry;
87
+ }>, toolUses: Map<string, ToolUseInfo>): {
88
+ corrupted: CorruptedEntry[];
89
+ entriesToRemove: Set<string>;
90
+ };
91
+ /**
92
+ * Compute parent chain relinks after removing entries
93
+ */
94
+ declare function computeParentRelinks(entries: Array<{
95
+ line: number;
96
+ entry: TranscriptEntry;
97
+ }>, entriesToRemove: Set<string>): ParentRelink[];
98
+ /**
99
+ * Analyze a session transcript for corruption without modifying it
100
+ */
101
+ declare function analyzeSession(filePath: string): RepairResult;
102
+ /**
103
+ * Repair a session transcript
104
+ */
105
+ declare function repairSession(filePath: string, options?: {
106
+ backup?: boolean;
107
+ dryRun?: boolean;
108
+ }): RepairResult;
109
+
110
+ export { type CorruptedEntry, type ParentRelink, type RepairResult, type ToolUseInfo, type TranscriptEntry, analyzeSession, computeParentRelinks, extractToolUses, findCorruptedEntries, parseTranscript, repairSession };
@@ -0,0 +1,16 @@
1
+ import {
2
+ analyzeSession,
3
+ computeParentRelinks,
4
+ extractToolUses,
5
+ findCorruptedEntries,
6
+ parseTranscript,
7
+ repairSession
8
+ } from "../chunk-L53L5FCL.js";
9
+ export {
10
+ analyzeSession,
11
+ computeParentRelinks,
12
+ extractToolUses,
13
+ findCorruptedEntries,
14
+ parseTranscript,
15
+ repairSession
16
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Session discovery utilities for OpenClaw transcripts
3
+ */
4
+ interface SessionInfo {
5
+ sessionId: string;
6
+ sessionKey: string;
7
+ agentId: string;
8
+ filePath: string;
9
+ updatedAt?: number;
10
+ }
11
+ interface SessionsStore {
12
+ [sessionKey: string]: {
13
+ sessionId: string;
14
+ updatedAt?: number;
15
+ [key: string]: unknown;
16
+ };
17
+ }
18
+ /**
19
+ * Get the OpenClaw agents directory
20
+ */
21
+ declare function getOpenClawAgentsDir(): string;
22
+ /**
23
+ * Get the sessions directory for an agent
24
+ */
25
+ declare function getSessionsDir(agentId: string): string;
26
+ /**
27
+ * Get the path to sessions.json for an agent
28
+ */
29
+ declare function getSessionsJsonPath(agentId: string): string;
30
+ /**
31
+ * Get the path to a session JSONL file
32
+ */
33
+ declare function getSessionFilePath(agentId: string, sessionId: string): string;
34
+ /**
35
+ * List all available agents
36
+ */
37
+ declare function listAgents(): string[];
38
+ /**
39
+ * Load sessions.json for an agent
40
+ */
41
+ declare function loadSessionsStore(agentId: string): SessionsStore | null;
42
+ /**
43
+ * Find the current/main session for an agent
44
+ */
45
+ declare function findMainSession(agentId: string): SessionInfo | null;
46
+ /**
47
+ * Find a session by ID
48
+ */
49
+ declare function findSessionById(agentId: string, sessionId: string): SessionInfo | null;
50
+ /**
51
+ * List all sessions for an agent
52
+ */
53
+ declare function listSessions(agentId: string): SessionInfo[];
54
+ /**
55
+ * Create a backup of a session file
56
+ */
57
+ declare function backupSession(filePath: string): string;
58
+
59
+ export { type SessionInfo, type SessionsStore, backupSession, findMainSession, findSessionById, getOpenClawAgentsDir, getSessionFilePath, getSessionsDir, getSessionsJsonPath, listAgents, listSessions, loadSessionsStore };
@@ -0,0 +1,24 @@
1
+ import {
2
+ backupSession,
3
+ findMainSession,
4
+ findSessionById,
5
+ getOpenClawAgentsDir,
6
+ getSessionFilePath,
7
+ getSessionsDir,
8
+ getSessionsJsonPath,
9
+ listAgents,
10
+ listSessions,
11
+ loadSessionsStore
12
+ } from "../chunk-AZRV2I5U.js";
13
+ export {
14
+ backupSession,
15
+ findMainSession,
16
+ findSessionById,
17
+ getOpenClawAgentsDir,
18
+ getSessionFilePath,
19
+ getSessionsDir,
20
+ getSessionsJsonPath,
21
+ listAgents,
22
+ listSessions,
23
+ loadSessionsStore
24
+ };
@@ -4,17 +4,54 @@
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
+ *
8
+ * SECURITY: Uses execFileSync (no shell) to prevent command injection
7
9
  */
8
10
 
9
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
10
12
  import * as fs from 'fs';
11
13
  import * as path from 'path';
12
14
 
15
+ // Sanitize string for safe display (prevent prompt injection via control chars)
16
+ function sanitizeForDisplay(str) {
17
+ if (typeof str !== 'string') return '';
18
+ // Remove control characters, limit length, escape markdown
19
+ return str
20
+ .replace(/[\x00-\x1f\x7f]/g, '') // Remove control chars
21
+ .replace(/[`*_~\[\]]/g, '\\$&') // Escape markdown
22
+ .slice(0, 200); // Limit length
23
+ }
24
+
25
+ // Validate vault path - must be absolute and exist
26
+ function validateVaultPath(vaultPath) {
27
+ if (!vaultPath || typeof vaultPath !== 'string') return null;
28
+
29
+ // Resolve to absolute path
30
+ const resolved = path.resolve(vaultPath);
31
+
32
+ // Must be absolute
33
+ if (!path.isAbsolute(resolved)) return null;
34
+
35
+ // Must exist and be a directory
36
+ try {
37
+ const stat = fs.statSync(resolved);
38
+ if (!stat.isDirectory()) return null;
39
+ } catch {
40
+ return null;
41
+ }
42
+
43
+ // Must contain .clawvault.json
44
+ const configPath = path.join(resolved, '.clawvault.json');
45
+ if (!fs.existsSync(configPath)) return null;
46
+
47
+ return resolved;
48
+ }
49
+
13
50
  // Find vault by walking up directories
14
51
  function findVaultPath() {
15
52
  // Check env first
16
53
  if (process.env.CLAWVAULT_PATH) {
17
- return process.env.CLAWVAULT_PATH;
54
+ return validateVaultPath(process.env.CLAWVAULT_PATH);
18
55
  }
19
56
 
20
57
  // Walk up from cwd
@@ -22,28 +59,31 @@ function findVaultPath() {
22
59
  const root = path.parse(dir).root;
23
60
 
24
61
  while (dir !== root) {
25
- const configPath = path.join(dir, '.clawvault.json');
26
- if (fs.existsSync(configPath)) {
27
- return dir;
28
- }
62
+ const validated = validateVaultPath(dir);
63
+ if (validated) return validated;
64
+
29
65
  // Also check memory/ subdirectory (OpenClaw convention)
30
- const memoryConfig = path.join(dir, 'memory', '.clawvault.json');
31
- if (fs.existsSync(memoryConfig)) {
32
- return path.join(dir, 'memory');
33
- }
66
+ const memoryDir = path.join(dir, 'memory');
67
+ const memoryValidated = validateVaultPath(memoryDir);
68
+ if (memoryValidated) return memoryValidated;
69
+
34
70
  dir = path.dirname(dir);
35
71
  }
36
72
 
37
73
  return null;
38
74
  }
39
75
 
40
- // Run clawvault command
76
+ // Run clawvault command safely (no shell)
41
77
  function runClawvault(args) {
42
78
  try {
43
- const output = execSync(`clawvault ${args.join(' ')}`, {
79
+ // Use execFileSync to avoid shell injection
80
+ // Arguments are passed as array, not interpolated into shell
81
+ const output = execFileSync('clawvault', args, {
44
82
  encoding: 'utf-8',
45
83
  timeout: 15000,
46
- stdio: ['pipe', 'pipe', 'pipe']
84
+ stdio: ['pipe', 'pipe', 'pipe'],
85
+ // Explicitly no shell
86
+ shell: false
47
87
  });
48
88
  return { success: true, output: output.trim(), code: 0 };
49
89
  } catch (err) {
@@ -55,6 +95,31 @@ function runClawvault(args) {
55
95
  }
56
96
  }
57
97
 
98
+ // Parse recovery output safely
99
+ function parseRecoveryOutput(output) {
100
+ if (!output || typeof output !== 'string') {
101
+ return { hadDeath: false, workingOn: null };
102
+ }
103
+
104
+ const hadDeath = output.includes('Context death detected') ||
105
+ output.includes('died') ||
106
+ output.includes('⚠️');
107
+
108
+ let workingOn = null;
109
+ if (hadDeath) {
110
+ const lines = output.split('\n');
111
+ const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
112
+ if (workingOnLine) {
113
+ const parts = workingOnLine.split(':');
114
+ if (parts.length > 1) {
115
+ workingOn = sanitizeForDisplay(parts.slice(1).join(':').trim());
116
+ }
117
+ }
118
+ }
119
+
120
+ return { hadDeath, workingOn };
121
+ }
122
+
58
123
  // Handle gateway startup - check for context death
59
124
  async function handleStartup(event) {
60
125
  const vaultPath = findVaultPath();
@@ -63,36 +128,34 @@ async function handleStartup(event) {
63
128
  return;
64
129
  }
65
130
 
66
- console.log(`[clawvault] Checking for context death (vault: ${vaultPath})`);
131
+ console.log(`[clawvault] Checking for context death`);
67
132
 
133
+ // Pass vault path as separate argument (not interpolated)
68
134
  const result = runClawvault(['recover', '--clear', '-v', vaultPath]);
69
135
 
70
136
  if (!result.success) {
71
- console.warn('[clawvault] Recovery check failed:', result.output);
137
+ console.warn('[clawvault] Recovery check failed');
72
138
  return;
73
139
  }
74
140
 
75
- // Parse output to detect if there was a death
76
- const output = result.output;
141
+ const { hadDeath, workingOn } = parseRecoveryOutput(result.output);
77
142
 
78
- if (output.includes('Context death detected') || output.includes('died') || output.includes('⚠️')) {
79
- // Extract relevant info
80
- const lines = output.split('\n');
81
- const workingOnLine = lines.find(l => l.toLowerCase().includes('working on'));
82
- const workingOn = workingOnLine ? workingOnLine.split(':').slice(1).join(':').trim() : null;
143
+ if (hadDeath) {
144
+ // Build safe alert message with sanitized content
145
+ const alertParts = ['[ClawVault] Context death detected.'];
146
+ if (workingOn) {
147
+ alertParts.push(`Last working on: ${workingOn}`);
148
+ }
149
+ alertParts.push('Run `clawvault wake` for full recovery context.');
83
150
 
84
- const alertMsg = [
85
- '⚠️ **Context Death Detected**',
86
- workingOn ? `Last working on: ${workingOn}` : null,
87
- 'Run `clawvault wake` for full recovery context.'
88
- ].filter(Boolean).join('\n');
151
+ const alertMsg = alertParts.join(' ');
89
152
 
90
153
  // Inject into event messages if available
91
154
  if (event.messages && Array.isArray(event.messages)) {
92
155
  event.messages.push(alertMsg);
93
156
  }
94
157
 
95
- console.warn('[clawvault] ⚠️ Context death detected, alert injected');
158
+ console.warn('[clawvault] Context death detected, alert injected');
96
159
  } else {
97
160
  console.log('[clawvault] Clean startup - no context death');
98
161
  }
@@ -106,24 +169,28 @@ async function handleNew(event) {
106
169
  return;
107
170
  }
108
171
 
109
- // Build checkpoint info from event context
110
- const sessionKey = event.sessionKey || 'unknown';
111
- const source = event.context?.commandSource || 'cli';
112
- const workingOn = `Session reset via /new from ${source}`;
172
+ // Sanitize session info for checkpoint
173
+ const sessionKey = typeof event.sessionKey === 'string'
174
+ ? event.sessionKey.replace(/[^a-zA-Z0-9:_-]/g, '').slice(0, 100)
175
+ : 'unknown';
176
+ const source = typeof event.context?.commandSource === 'string'
177
+ ? event.context.commandSource.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 50)
178
+ : 'cli';
113
179
 
114
- console.log(`[clawvault] Auto-checkpoint before /new (session: ${sessionKey})`);
180
+ console.log('[clawvault] Auto-checkpoint before /new');
115
181
 
182
+ // Pass each argument separately (no shell interpolation)
116
183
  const result = runClawvault([
117
184
  'checkpoint',
118
- '--working-on', `"${workingOn}"`,
119
- '--focus', `"Pre-reset checkpoint, session: ${sessionKey}"`,
185
+ '--working-on', `Session reset via /new from ${source}`,
186
+ '--focus', `Pre-reset checkpoint, session: ${sessionKey}`,
120
187
  '-v', vaultPath
121
188
  ]);
122
189
 
123
190
  if (result.success) {
124
191
  console.log('[clawvault] Auto-checkpoint created');
125
192
  } else {
126
- console.warn('[clawvault] Auto-checkpoint failed:', result.output);
193
+ console.warn('[clawvault] Auto-checkpoint failed');
127
194
  }
128
195
  }
129
196
 
@@ -140,7 +207,7 @@ const handler = async (event) => {
140
207
  return;
141
208
  }
142
209
  } catch (err) {
143
- console.error('[clawvault] Hook error:', err);
210
+ console.error('[clawvault] Hook error:', err.message || 'unknown error');
144
211
  }
145
212
  };
146
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawvault",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
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/wake.ts src/commands/sleep.ts src/commands/doctor.ts src/commands/shell-init.ts src/lib/entity-index.ts src/lib/auto-linker.ts src/lib/config.ts src/lib/template-engine.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/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",
@@ -65,8 +65,16 @@
65
65
  "chalk": "^5.3.0",
66
66
  "glob": "^10.3.10",
67
67
  "gray-matter": "^4.0.3",
68
- "natural": "^6.10.4",
69
- "qmd": "github:tobi/qmd"
68
+ "natural": "^6.10.4"
69
+ },
70
+ "optionalDependencies": {},
71
+ "peerDependencies": {
72
+ "qmd": "*"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "qmd": {
76
+ "optional": true
77
+ }
70
78
  },
71
79
  "devDependencies": {
72
80
  "@types/node": "^20.11.0",