claude-context-saver 1.0.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/LICENSE +21 -0
- package/README.md +172 -0
- package/backup-core.mjs +306 -0
- package/conv-backup.mjs +47 -0
- package/package.json +41 -0
- package/setup.mjs +197 -0
- package/statusline-monitor.mjs +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 simsibaq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# claude-context-saver
|
|
2
|
+
|
|
3
|
+
Automatic context backup for Claude Code. Saves your conversation state before auto-compaction wipes it.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
Claude Code auto-compacts conversations when the context window fills up, losing detailed session history. This package monitors token usage and creates structured markdown backups before that happens.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
### Default — local (this project only)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx claude-context-saver
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Hooks install to `.claude/` in the current project directory.
|
|
18
|
+
|
|
19
|
+
### Global (all projects)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx claude-context-saver -g
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Hooks install to `~/.claude/` — active for every project.
|
|
26
|
+
|
|
27
|
+
### Other methods
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Global install via npm
|
|
31
|
+
npm install -g claude-context-saver
|
|
32
|
+
claude-context-saver # local (default)
|
|
33
|
+
claude-context-saver -g # global
|
|
34
|
+
|
|
35
|
+
# Manual / from source
|
|
36
|
+
git clone https://github.com/panbergco/claude-context-saver.git
|
|
37
|
+
cd claude-context-saver
|
|
38
|
+
node setup.mjs # local (default)
|
|
39
|
+
node setup.mjs -g # global
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart Claude Code after installing.
|
|
43
|
+
|
|
44
|
+
## Uninstall
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Local
|
|
48
|
+
npx claude-context-saver -u
|
|
49
|
+
|
|
50
|
+
# Global
|
|
51
|
+
npx claude-context-saver -g -u
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How It Works
|
|
55
|
+
|
|
56
|
+
### StatusLine Monitor
|
|
57
|
+
|
|
58
|
+
Runs every turn. Reads token usage from Claude Code and displays a live status line:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
[!] Opus 4.6 | 65k/200k | 32% used | 51% free -> backup-3.md
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Triggers backups based on dual thresholds:
|
|
65
|
+
|
|
66
|
+
- **Token-based**: First backup at 50K tokens, then every 10K (60K, 70K, 80K...)
|
|
67
|
+
- **Percentage-based**: At 30%, 15%, 5% free-until-compaction, continuous under 5%
|
|
68
|
+
|
|
69
|
+
Accounts for Claude Code's 33K auto-compaction buffer in free-space calculations.
|
|
70
|
+
|
|
71
|
+
### PreCompact Hook
|
|
72
|
+
|
|
73
|
+
Fires right before compaction as a final safety net. Synchronously reads the full conversation transcript before compaction can modify it.
|
|
74
|
+
|
|
75
|
+
### Backups
|
|
76
|
+
|
|
77
|
+
Numbered markdown files saved to `{project}/.claude/backups/`:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
1-backup-2026-03-07-14-15.md
|
|
81
|
+
2-backup-2026-03-07-15-30.md
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each backup contains:
|
|
85
|
+
- User requests
|
|
86
|
+
- Files modified (Write/Edit operations)
|
|
87
|
+
- Tasks created/updated
|
|
88
|
+
- Skills invoked
|
|
89
|
+
- Bash commands run
|
|
90
|
+
- Key assistant responses
|
|
91
|
+
- Token state at time of backup
|
|
92
|
+
|
|
93
|
+
## File Layout After Install
|
|
94
|
+
|
|
95
|
+
### Local mode (default) — `{project}/.claude/`
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
your-project/
|
|
99
|
+
└── .claude/
|
|
100
|
+
├── hooks/ContextRecoveryHook/
|
|
101
|
+
│ ├── backup-core.mjs
|
|
102
|
+
│ ├── statusline-monitor.mjs
|
|
103
|
+
│ └── conv-backup.mjs
|
|
104
|
+
├── settings.json (statusLine + PreCompact hook added)
|
|
105
|
+
└── backups/
|
|
106
|
+
├── 1-backup-2026-03-07-14-15.md
|
|
107
|
+
└── 2-backup-2026-03-07-15-30.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Global mode (`-g`) — `~/.claude/`
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
~/.claude/
|
|
114
|
+
├── hooks/ContextRecoveryHook/
|
|
115
|
+
│ ├── backup-core.mjs
|
|
116
|
+
│ ├── statusline-monitor.mjs
|
|
117
|
+
│ └── conv-backup.mjs
|
|
118
|
+
├── settings.json (statusLine + PreCompact hook added)
|
|
119
|
+
└── claudefast-statusline-state.json (runtime state)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Settings.json Changes
|
|
123
|
+
|
|
124
|
+
The installer merges these entries into your settings (all existing settings preserved):
|
|
125
|
+
|
|
126
|
+
**Local** (default) uses relative paths:
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"statusLine": {
|
|
130
|
+
"type": "command",
|
|
131
|
+
"command": "node \".claude/hooks/ContextRecoveryHook/statusline-monitor.mjs\""
|
|
132
|
+
},
|
|
133
|
+
"hooks": {
|
|
134
|
+
"PreCompact": [{
|
|
135
|
+
"hooks": [{
|
|
136
|
+
"type": "command",
|
|
137
|
+
"command": "node \".claude/hooks/ContextRecoveryHook/conv-backup.mjs\"",
|
|
138
|
+
"async": true
|
|
139
|
+
}]
|
|
140
|
+
}]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Global** (`-g`) uses `$HOME` paths:
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"statusLine": {
|
|
149
|
+
"type": "command",
|
|
150
|
+
"command": "node \"$HOME/.claude/hooks/ContextRecoveryHook/statusline-monitor.mjs\""
|
|
151
|
+
},
|
|
152
|
+
"hooks": {
|
|
153
|
+
"PreCompact": [{
|
|
154
|
+
"hooks": [{
|
|
155
|
+
"type": "command",
|
|
156
|
+
"command": "node \"$HOME/.claude/hooks/ContextRecoveryHook/conv-backup.mjs\"",
|
|
157
|
+
"async": true
|
|
158
|
+
}]
|
|
159
|
+
}]
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Requirements
|
|
165
|
+
|
|
166
|
+
- Node.js >= 18
|
|
167
|
+
- Claude Code CLI
|
|
168
|
+
- Zero npm dependencies
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
package/backup-core.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// backup-core.mjs — Engine: JSONL parsing, markdown backup, state management
|
|
2
|
+
// Pure Node.js ESM, zero dependencies
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { createReadStream } from 'node:fs';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
|
|
10
|
+
const STATE_PATH = join(homedir(), '.claude', 'claudefast-statusline-state.json');
|
|
11
|
+
|
|
12
|
+
// --- State Management ---
|
|
13
|
+
|
|
14
|
+
export function readState() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeState(state) {
|
|
23
|
+
try {
|
|
24
|
+
const dir = join(homedir(), '.claude');
|
|
25
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
// Concurrent access — silently fail, next write wins
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- JSONL Parsing ---
|
|
33
|
+
|
|
34
|
+
// Skip these message types — they're 91%+ of lines and not useful for backups
|
|
35
|
+
const SKIP_TYPES = new Set(['progress', 'file-history-snapshot']);
|
|
36
|
+
|
|
37
|
+
export async function parseTranscript(transcriptPath) {
|
|
38
|
+
const messages = [];
|
|
39
|
+
|
|
40
|
+
if (!existsSync(transcriptPath)) return messages;
|
|
41
|
+
|
|
42
|
+
const rl = createInterface({
|
|
43
|
+
input: createReadStream(transcriptPath, { encoding: 'utf8' }),
|
|
44
|
+
crlfDelay: Infinity,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for await (const line of rl) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(line);
|
|
51
|
+
if (obj.type && SKIP_TYPES.has(obj.type)) continue;
|
|
52
|
+
messages.push(obj);
|
|
53
|
+
} catch {
|
|
54
|
+
// Malformed line — skip
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return messages;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Synchronous version for PreCompact hook (must read before compaction starts)
|
|
62
|
+
export function parseTranscriptSync(transcriptPath) {
|
|
63
|
+
const messages = [];
|
|
64
|
+
|
|
65
|
+
if (!existsSync(transcriptPath)) return messages;
|
|
66
|
+
|
|
67
|
+
const content = readFileSync(transcriptPath, 'utf8');
|
|
68
|
+
for (const line of content.split('\n')) {
|
|
69
|
+
if (!line.trim()) continue;
|
|
70
|
+
try {
|
|
71
|
+
const obj = JSON.parse(line);
|
|
72
|
+
if (obj.type && SKIP_TYPES.has(obj.type)) continue;
|
|
73
|
+
messages.push(obj);
|
|
74
|
+
} catch {
|
|
75
|
+
// Malformed line — skip
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return messages;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Data Extraction ---
|
|
83
|
+
|
|
84
|
+
export function extractSessionData(messages) {
|
|
85
|
+
const data = {
|
|
86
|
+
userRequests: [],
|
|
87
|
+
fileModifications: [],
|
|
88
|
+
tasksCreated: [],
|
|
89
|
+
tasksUpdated: [],
|
|
90
|
+
skillCalls: [],
|
|
91
|
+
bashCommands: [],
|
|
92
|
+
keyResponses: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for (const msg of messages) {
|
|
96
|
+
// User messages / requests
|
|
97
|
+
if (msg.type === 'human' || msg.role === 'user') {
|
|
98
|
+
const text = extractText(msg);
|
|
99
|
+
if (text) data.userRequests.push(truncate(text, 500));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Assistant messages — scan for tool use
|
|
103
|
+
if (msg.type === 'assistant' || msg.role === 'assistant') {
|
|
104
|
+
const content = msg.content || msg.message?.content;
|
|
105
|
+
if (!content) continue;
|
|
106
|
+
|
|
107
|
+
const blocks = Array.isArray(content) ? content : [content];
|
|
108
|
+
|
|
109
|
+
for (const block of blocks) {
|
|
110
|
+
if (typeof block === 'string') {
|
|
111
|
+
// Text response — capture if substantial
|
|
112
|
+
if (block.length > 100) {
|
|
113
|
+
data.keyResponses.push(truncate(block, 300));
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (block.type === 'text' && block.text && block.text.length > 100) {
|
|
119
|
+
data.keyResponses.push(truncate(block.text, 300));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (block.type !== 'tool_use') continue;
|
|
124
|
+
|
|
125
|
+
const name = block.name;
|
|
126
|
+
const input = block.input || {};
|
|
127
|
+
|
|
128
|
+
if (name === 'Write' || name === 'Edit') {
|
|
129
|
+
const fp = input.file_path;
|
|
130
|
+
if (fp) data.fileModifications.push(fp);
|
|
131
|
+
} else if (name === 'TaskCreate') {
|
|
132
|
+
data.tasksCreated.push(input.subject || input.description || '(untitled)');
|
|
133
|
+
} else if (name === 'TaskUpdate') {
|
|
134
|
+
const desc = input.status
|
|
135
|
+
? `#${input.taskId} → ${input.status}`
|
|
136
|
+
: `#${input.taskId} updated`;
|
|
137
|
+
data.tasksUpdated.push(desc);
|
|
138
|
+
} else if (name === 'Skill') {
|
|
139
|
+
data.skillCalls.push(input.skill || '(unknown)');
|
|
140
|
+
} else if (name === 'Bash') {
|
|
141
|
+
const cmd = input.command;
|
|
142
|
+
if (cmd) data.bashCommands.push(truncate(cmd, 200));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Deduplicate file modifications
|
|
149
|
+
data.fileModifications = [...new Set(data.fileModifications)];
|
|
150
|
+
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function extractText(msg) {
|
|
155
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
156
|
+
if (msg.message?.content && typeof msg.message.content === 'string') return msg.message.content;
|
|
157
|
+
const content = msg.content || msg.message?.content;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
return content
|
|
160
|
+
.filter(b => b.type === 'text')
|
|
161
|
+
.map(b => b.text)
|
|
162
|
+
.join('\n');
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function truncate(str, max) {
|
|
168
|
+
if (str.length <= max) return str;
|
|
169
|
+
return str.slice(0, max) + '...';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Markdown Formatting ---
|
|
173
|
+
|
|
174
|
+
export function formatBackupMarkdown(data, meta) {
|
|
175
|
+
const lines = [];
|
|
176
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
177
|
+
|
|
178
|
+
lines.push(`# Context Backup`);
|
|
179
|
+
lines.push(`- **Date**: ${ts}`);
|
|
180
|
+
lines.push(`- **Session**: ${meta.sessionId || 'unknown'}`);
|
|
181
|
+
lines.push(`- **Trigger**: ${meta.trigger || 'unknown'}`);
|
|
182
|
+
if (meta.tokensUsed) lines.push(`- **Tokens Used**: ${meta.tokensUsed.toLocaleString()}`);
|
|
183
|
+
if (meta.windowSize) lines.push(`- **Context Window**: ${meta.windowSize.toLocaleString()}`);
|
|
184
|
+
if (meta.freePercent != null) lines.push(`- **Free Until Compaction**: ${meta.freePercent.toFixed(1)}%`);
|
|
185
|
+
lines.push('');
|
|
186
|
+
|
|
187
|
+
if (data.userRequests.length) {
|
|
188
|
+
lines.push(`## User Requests`);
|
|
189
|
+
for (const r of data.userRequests) {
|
|
190
|
+
lines.push(`- ${r.replace(/\n/g, ' ').trim()}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (data.fileModifications.length) {
|
|
196
|
+
lines.push(`## Files Modified`);
|
|
197
|
+
for (const f of data.fileModifications) {
|
|
198
|
+
lines.push(`- \`${f}\``);
|
|
199
|
+
}
|
|
200
|
+
lines.push('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (data.tasksCreated.length) {
|
|
204
|
+
lines.push(`## Tasks Created`);
|
|
205
|
+
for (const t of data.tasksCreated) {
|
|
206
|
+
lines.push(`- ${t}`);
|
|
207
|
+
}
|
|
208
|
+
lines.push('');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (data.tasksUpdated.length) {
|
|
212
|
+
lines.push(`## Tasks Updated`);
|
|
213
|
+
for (const t of data.tasksUpdated) {
|
|
214
|
+
lines.push(`- ${t}`);
|
|
215
|
+
}
|
|
216
|
+
lines.push('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (data.skillCalls.length) {
|
|
220
|
+
lines.push(`## Skills Used`);
|
|
221
|
+
for (const s of data.skillCalls) {
|
|
222
|
+
lines.push(`- ${s}`);
|
|
223
|
+
}
|
|
224
|
+
lines.push('');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (data.bashCommands.length) {
|
|
228
|
+
lines.push(`## Bash Commands`);
|
|
229
|
+
for (const c of data.bashCommands) {
|
|
230
|
+
lines.push(`- \`${c.replace(/\n/g, ' ')}\``);
|
|
231
|
+
}
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (data.keyResponses.length) {
|
|
236
|
+
lines.push(`## Key Responses`);
|
|
237
|
+
for (const r of data.keyResponses.slice(0, 10)) {
|
|
238
|
+
lines.push(`> ${r.replace(/\n/g, ' ').trim()}`);
|
|
239
|
+
lines.push('');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Backup Path ---
|
|
247
|
+
|
|
248
|
+
export function getBackupPath(projectDir) {
|
|
249
|
+
const backupDir = join(projectDir, '.claude', 'backups');
|
|
250
|
+
mkdirSync(backupDir, { recursive: true });
|
|
251
|
+
|
|
252
|
+
// Find next number
|
|
253
|
+
let maxNum = 0;
|
|
254
|
+
try {
|
|
255
|
+
const files = readdirSync(backupDir);
|
|
256
|
+
for (const f of files) {
|
|
257
|
+
const match = f.match(/^(\d+)-backup-/);
|
|
258
|
+
if (match) {
|
|
259
|
+
const n = parseInt(match[1], 10);
|
|
260
|
+
if (n > maxNum) maxNum = n;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// Directory read failed — start at 1
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const num = maxNum + 1;
|
|
268
|
+
const now = new Date();
|
|
269
|
+
const dateStr = [
|
|
270
|
+
now.getFullYear(),
|
|
271
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
272
|
+
String(now.getDate()).padStart(2, '0'),
|
|
273
|
+
].join('-');
|
|
274
|
+
const timeStr = [
|
|
275
|
+
String(now.getHours()).padStart(2, '0'),
|
|
276
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
277
|
+
].join('-');
|
|
278
|
+
|
|
279
|
+
const filename = `${num}-backup-${dateStr}-${timeStr}.md`;
|
|
280
|
+
return { dir: backupDir, path: join(backupDir, filename), num };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Orchestrator ---
|
|
284
|
+
|
|
285
|
+
export async function createBackup({ transcriptPath, sessionId, projectDir, trigger, tokensUsed, windowSize, freePercent, sync = false }) {
|
|
286
|
+
try {
|
|
287
|
+
const messages = sync
|
|
288
|
+
? parseTranscriptSync(transcriptPath)
|
|
289
|
+
: await parseTranscript(transcriptPath);
|
|
290
|
+
|
|
291
|
+
if (messages.length === 0) return null;
|
|
292
|
+
|
|
293
|
+
const data = extractSessionData(messages);
|
|
294
|
+
const meta = { sessionId, trigger, tokensUsed, windowSize, freePercent };
|
|
295
|
+
const markdown = formatBackupMarkdown(data, meta);
|
|
296
|
+
|
|
297
|
+
const { path, num } = getBackupPath(projectDir);
|
|
298
|
+
writeFileSync(path, markdown, 'utf8');
|
|
299
|
+
|
|
300
|
+
return { path, num };
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// Backup should never crash the host process
|
|
303
|
+
process.stderr.write(`[context-recovery] Backup failed: ${err.message}\n`);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
package/conv-backup.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// conv-backup.mjs — PreCompact hook: last-chance backup before auto-compaction
|
|
2
|
+
// CRITICAL: Must read JSONL synchronously BEFORE compaction modifies it
|
|
3
|
+
// Runs async (won't block compaction, but reads data first)
|
|
4
|
+
|
|
5
|
+
import { createBackup } from './backup-core.mjs';
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
// Read JSON from stdin
|
|
9
|
+
let input = '';
|
|
10
|
+
for await (const chunk of process.stdin) {
|
|
11
|
+
input += chunk;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let data;
|
|
15
|
+
try {
|
|
16
|
+
data = JSON.parse(input);
|
|
17
|
+
} catch {
|
|
18
|
+
process.stderr.write('[context-recovery] PreCompact: invalid stdin\n');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sessionId = data.session_id || 'unknown';
|
|
23
|
+
const transcriptPath = data.transcript_path || '';
|
|
24
|
+
const projectDir = data.workspace?.project_dir || data.workspace?.cwd || process.cwd();
|
|
25
|
+
|
|
26
|
+
// Determine trigger type
|
|
27
|
+
const trigger = data.trigger === 'manual'
|
|
28
|
+
? 'precompact_manual'
|
|
29
|
+
: 'precompact_auto';
|
|
30
|
+
|
|
31
|
+
// Use sync mode — must read JSONL before compaction modifies it
|
|
32
|
+
const result = await createBackup({
|
|
33
|
+
transcriptPath,
|
|
34
|
+
sessionId,
|
|
35
|
+
projectDir,
|
|
36
|
+
trigger,
|
|
37
|
+
sync: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result) {
|
|
41
|
+
process.stderr.write(`[context-recovery] PreCompact backup saved: ${result.path}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch(err => {
|
|
46
|
+
process.stderr.write(`[context-recovery] PreCompact error: ${err.message}\n`);
|
|
47
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-context-saver",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automatic context backup for Claude Code — saves conversation state before auto-compaction wipes it",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-context-saver": "./setup.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"install-hooks": "node setup.mjs",
|
|
11
|
+
"uninstall-hooks": "node setup.mjs --uninstall"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"backup-core.mjs",
|
|
15
|
+
"statusline-monitor.mjs",
|
|
16
|
+
"conv-backup.mjs",
|
|
17
|
+
"setup.mjs"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"context",
|
|
23
|
+
"backup",
|
|
24
|
+
"compaction",
|
|
25
|
+
"hooks",
|
|
26
|
+
"statusline"
|
|
27
|
+
],
|
|
28
|
+
"author": "panbergco",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/panbergco/claude-context-saver.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/panbergco/claude-context-saver#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/panbergco/claude-context-saver/issues"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/setup.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// setup.mjs — One-command installer for Context Recovery Hook
|
|
3
|
+
// Usage: node setup.mjs [-g] [--uninstall]
|
|
4
|
+
// Default: local install to project .claude/
|
|
5
|
+
// -g: install globally to ~/.claude/ (all projects)
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join, dirname, resolve } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const isGlobal = args.includes('-g') || args.includes('--global');
|
|
17
|
+
const isLocal = !isGlobal;
|
|
18
|
+
const isUninstall = args.includes('--uninstall') || args.includes('-u');
|
|
19
|
+
const isHelp = args.includes('--help') || args.includes('-h');
|
|
20
|
+
|
|
21
|
+
// Resolve target directories based on mode
|
|
22
|
+
const CLAUDE_DIR = isLocal
|
|
23
|
+
? join(process.cwd(), '.claude')
|
|
24
|
+
: join(homedir(), '.claude');
|
|
25
|
+
const HOOKS_DIR = join(CLAUDE_DIR, 'hooks', 'ContextRecoveryHook');
|
|
26
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
|
|
27
|
+
|
|
28
|
+
// Command paths used inside settings.json
|
|
29
|
+
// Global: use $HOME so it works across shells (Git Bash, etc.)
|
|
30
|
+
// Local: use .claude/ relative path (Claude Code runs from project root)
|
|
31
|
+
const HOOK_CMD_PREFIX = isLocal
|
|
32
|
+
? '.claude/hooks/ContextRecoveryHook'
|
|
33
|
+
: '$HOME/.claude/hooks/ContextRecoveryHook';
|
|
34
|
+
|
|
35
|
+
const SCRIPTS = ['backup-core.mjs', 'statusline-monitor.mjs', 'conv-backup.mjs'];
|
|
36
|
+
|
|
37
|
+
function makeConfigs() {
|
|
38
|
+
return {
|
|
39
|
+
statusLine: {
|
|
40
|
+
type: 'command',
|
|
41
|
+
command: `node "${HOOK_CMD_PREFIX}/statusline-monitor.mjs"`,
|
|
42
|
+
},
|
|
43
|
+
preCompact: [{
|
|
44
|
+
hooks: [{
|
|
45
|
+
type: 'command',
|
|
46
|
+
command: `node "${HOOK_CMD_PREFIX}/conv-backup.mjs"`,
|
|
47
|
+
async: true,
|
|
48
|
+
}],
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readSettings() {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeSettings(settings) {
|
|
62
|
+
mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
63
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function install() {
|
|
67
|
+
const mode = isLocal ? 'local (this project only)' : 'global (all projects)';
|
|
68
|
+
console.log(`Context Recovery Hook — Installing ${mode}...\n`);
|
|
69
|
+
|
|
70
|
+
// 1. Copy scripts to hooks dir
|
|
71
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
72
|
+
for (const script of SCRIPTS) {
|
|
73
|
+
const src = join(__dirname, script);
|
|
74
|
+
const dst = join(HOOKS_DIR, script);
|
|
75
|
+
cpSync(src, dst, { force: true });
|
|
76
|
+
console.log(` Copied: ${script}`);
|
|
77
|
+
}
|
|
78
|
+
console.log(` -> ${HOOKS_DIR}\n`);
|
|
79
|
+
|
|
80
|
+
// 2. Update settings.json
|
|
81
|
+
const settings = readSettings();
|
|
82
|
+
const configs = makeConfigs();
|
|
83
|
+
|
|
84
|
+
// Check for existing statusLine
|
|
85
|
+
if (settings.statusLine && settings.statusLine.command && !settings.statusLine.command.includes('ContextRecoveryHook')) {
|
|
86
|
+
console.log(` WARNING: Existing statusLine config will be overwritten:`);
|
|
87
|
+
console.log(` Old: ${JSON.stringify(settings.statusLine)}`);
|
|
88
|
+
console.log(` New: ${JSON.stringify(configs.statusLine)}`);
|
|
89
|
+
console.log('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
settings.statusLine = configs.statusLine;
|
|
93
|
+
|
|
94
|
+
// Merge PreCompact hook — preserve existing hooks
|
|
95
|
+
if (!settings.hooks) settings.hooks = {};
|
|
96
|
+
|
|
97
|
+
const existingPreCompact = settings.hooks.PreCompact || [];
|
|
98
|
+
const alreadyInstalled = existingPreCompact.some(entry =>
|
|
99
|
+
entry.hooks?.some(h => h.command?.includes('ContextRecoveryHook'))
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (alreadyInstalled) {
|
|
103
|
+
console.log(' PreCompact hook already registered — skipping.');
|
|
104
|
+
} else {
|
|
105
|
+
settings.hooks.PreCompact = [...existingPreCompact, ...configs.preCompact];
|
|
106
|
+
console.log(' Added PreCompact hook.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
writeSettings(settings);
|
|
110
|
+
console.log(` Updated: ${SETTINGS_PATH}\n`);
|
|
111
|
+
|
|
112
|
+
console.log('Installation complete!\n');
|
|
113
|
+
if (isLocal) {
|
|
114
|
+
console.log('Mode: local (this project only)');
|
|
115
|
+
console.log(`Hooks installed to: .claude/hooks/ContextRecoveryHook/`);
|
|
116
|
+
console.log(`Settings updated: .claude/settings.json`);
|
|
117
|
+
} else {
|
|
118
|
+
console.log('Mode: global (all projects)');
|
|
119
|
+
console.log('Hooks installed to: ~/.claude/hooks/ContextRecoveryHook/');
|
|
120
|
+
console.log('Settings updated: ~/.claude/settings.json');
|
|
121
|
+
}
|
|
122
|
+
console.log('Backups will appear: {project}/.claude/backups/');
|
|
123
|
+
console.log('\nRestart Claude Code to activate.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function uninstall() {
|
|
127
|
+
const mode = isLocal ? 'local' : 'global';
|
|
128
|
+
console.log(`Context Recovery Hook — Uninstalling ${mode}...\n`);
|
|
129
|
+
|
|
130
|
+
// 1. Remove hooks directory
|
|
131
|
+
if (existsSync(HOOKS_DIR)) {
|
|
132
|
+
rmSync(HOOKS_DIR, { recursive: true, force: true });
|
|
133
|
+
console.log(` Removed: ${HOOKS_DIR}`);
|
|
134
|
+
} else {
|
|
135
|
+
console.log(' Hooks directory not found — skipping.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Clean settings.json
|
|
139
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
140
|
+
const settings = readSettings();
|
|
141
|
+
let changed = false;
|
|
142
|
+
|
|
143
|
+
// Remove statusLine if it's ours
|
|
144
|
+
if (settings.statusLine?.command?.includes('ContextRecoveryHook')) {
|
|
145
|
+
delete settings.statusLine;
|
|
146
|
+
console.log(' Removed statusLine config.');
|
|
147
|
+
changed = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Remove our PreCompact hook entry
|
|
151
|
+
if (settings.hooks?.PreCompact) {
|
|
152
|
+
const filtered = settings.hooks.PreCompact.filter(entry =>
|
|
153
|
+
!entry.hooks?.some(h => h.command?.includes('ContextRecoveryHook'))
|
|
154
|
+
);
|
|
155
|
+
if (filtered.length !== settings.hooks.PreCompact.length) {
|
|
156
|
+
settings.hooks.PreCompact = filtered.length > 0 ? filtered : undefined;
|
|
157
|
+
if (!settings.hooks.PreCompact) delete settings.hooks.PreCompact;
|
|
158
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
159
|
+
console.log(' Removed PreCompact hook.');
|
|
160
|
+
changed = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (changed) {
|
|
165
|
+
writeSettings(settings);
|
|
166
|
+
console.log(` Updated: ${SETTINGS_PATH}`);
|
|
167
|
+
} else {
|
|
168
|
+
console.log(' No hook config found in settings — skipping.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. Remove state file (only for global installs)
|
|
173
|
+
if (!isLocal) {
|
|
174
|
+
const statePath = join(CLAUDE_DIR, 'claudefast-statusline-state.json');
|
|
175
|
+
if (existsSync(statePath)) {
|
|
176
|
+
rmSync(statePath, { force: true });
|
|
177
|
+
console.log(' Removed state file.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log('\nUninstall complete. Restart Claude Code to apply.');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Main ---
|
|
185
|
+
if (isHelp) {
|
|
186
|
+
console.log('claude-context-saver — Setup\n');
|
|
187
|
+
console.log('Usage:');
|
|
188
|
+
console.log(' node setup.mjs Install for this project (.claude/)');
|
|
189
|
+
console.log(' node setup.mjs -g Install globally (~/.claude/)');
|
|
190
|
+
console.log(' node setup.mjs -u Uninstall local hooks');
|
|
191
|
+
console.log(' node setup.mjs -g -u Uninstall global hooks');
|
|
192
|
+
console.log('\nAfter install, restart Claude Code to activate.');
|
|
193
|
+
} else if (isUninstall) {
|
|
194
|
+
uninstall();
|
|
195
|
+
} else {
|
|
196
|
+
install();
|
|
197
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// statusline-monitor.mjs — StatusLine hook: monitors tokens, triggers backups, prints status
|
|
2
|
+
// Receives JSON on stdin from Claude Code every turn
|
|
3
|
+
// Must be FAST — gets cancelled if slow
|
|
4
|
+
|
|
5
|
+
import { readState, writeState, createBackup } from './backup-core.mjs';
|
|
6
|
+
|
|
7
|
+
const AUTO_COMPACT_BUFFER = 33_000;
|
|
8
|
+
|
|
9
|
+
// Token-based thresholds: first at 50K, then every 10K
|
|
10
|
+
const FIRST_TOKEN_THRESHOLD = 50_000;
|
|
11
|
+
const TOKEN_INCREMENT = 10_000;
|
|
12
|
+
|
|
13
|
+
// Percentage-based thresholds (free-until-compaction)
|
|
14
|
+
const PCT_THRESHOLDS = [30, 15, 5];
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
// Read JSON from stdin
|
|
18
|
+
let input = '';
|
|
19
|
+
for await (const chunk of process.stdin) {
|
|
20
|
+
input += chunk;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let data;
|
|
24
|
+
try {
|
|
25
|
+
data = JSON.parse(input);
|
|
26
|
+
} catch {
|
|
27
|
+
process.stdout.write('[?] No data');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sessionId = data.session_id || 'unknown';
|
|
32
|
+
const transcriptPath = data.transcript_path || '';
|
|
33
|
+
const tokensUsed = data.context_window?.tokens_used ?? 0;
|
|
34
|
+
const windowSize = data.context_window?.window_size ?? 200_000;
|
|
35
|
+
const modelName = data.model?.name || data.model?.model_id || 'unknown';
|
|
36
|
+
const projectDir = data.workspace?.project_dir || data.workspace?.cwd || process.cwd();
|
|
37
|
+
|
|
38
|
+
// Calculate free-until-compaction percentage
|
|
39
|
+
const freeTokens = windowSize - tokensUsed - AUTO_COMPACT_BUFFER;
|
|
40
|
+
const freePercent = Math.max(0, (freeTokens / windowSize) * 100);
|
|
41
|
+
|
|
42
|
+
// Format token counts for display
|
|
43
|
+
const usedK = Math.round(tokensUsed / 1000);
|
|
44
|
+
const windowK = Math.round(windowSize / 1000);
|
|
45
|
+
const usedPercent = Math.round((tokensUsed / windowSize) * 100);
|
|
46
|
+
const freeDisplay = Math.round(freePercent);
|
|
47
|
+
|
|
48
|
+
// Load/reset state for this session
|
|
49
|
+
let state = readState();
|
|
50
|
+
if (state.sessionId !== sessionId) {
|
|
51
|
+
state = {
|
|
52
|
+
sessionId,
|
|
53
|
+
lastTokenThreshold: 0,
|
|
54
|
+
triggeredPctThresholds: [],
|
|
55
|
+
backupCount: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let triggered = false;
|
|
60
|
+
let triggerReason = '';
|
|
61
|
+
|
|
62
|
+
// --- Token-based triggers ---
|
|
63
|
+
if (tokensUsed >= FIRST_TOKEN_THRESHOLD) {
|
|
64
|
+
// Calculate which threshold we should be at
|
|
65
|
+
const expectedThreshold = tokensUsed >= FIRST_TOKEN_THRESHOLD
|
|
66
|
+
? FIRST_TOKEN_THRESHOLD + Math.floor((tokensUsed - FIRST_TOKEN_THRESHOLD) / TOKEN_INCREMENT) * TOKEN_INCREMENT
|
|
67
|
+
: 0;
|
|
68
|
+
|
|
69
|
+
if (expectedThreshold > (state.lastTokenThreshold || 0)) {
|
|
70
|
+
triggered = true;
|
|
71
|
+
triggerReason = `tokens_${Math.round(tokensUsed / 1000)}k`;
|
|
72
|
+
state.lastTokenThreshold = expectedThreshold;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Percentage-based triggers ---
|
|
77
|
+
if (!triggered) {
|
|
78
|
+
const alreadyTriggered = new Set(state.triggeredPctThresholds || []);
|
|
79
|
+
|
|
80
|
+
for (const threshold of PCT_THRESHOLDS) {
|
|
81
|
+
if (freePercent <= threshold && !alreadyTriggered.has(threshold)) {
|
|
82
|
+
triggered = true;
|
|
83
|
+
triggerReason = `free_${threshold}pct`;
|
|
84
|
+
state.triggeredPctThresholds = [...alreadyTriggered, threshold];
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Continuous backup under 5% free
|
|
90
|
+
if (!triggered && freePercent < 5 && tokensUsed > (state.lastContinuousTokens || 0) + TOKEN_INCREMENT) {
|
|
91
|
+
triggered = true;
|
|
92
|
+
triggerReason = `continuous_${freeDisplay}pct`;
|
|
93
|
+
state.lastContinuousTokens = tokensUsed;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Perform backup if triggered ---
|
|
98
|
+
let backupLabel = '';
|
|
99
|
+
if (triggered) {
|
|
100
|
+
const result = await createBackup({
|
|
101
|
+
transcriptPath,
|
|
102
|
+
sessionId,
|
|
103
|
+
projectDir,
|
|
104
|
+
trigger: triggerReason,
|
|
105
|
+
tokensUsed,
|
|
106
|
+
windowSize,
|
|
107
|
+
freePercent,
|
|
108
|
+
});
|
|
109
|
+
if (result) {
|
|
110
|
+
state.backupCount = (state.backupCount || 0) + 1;
|
|
111
|
+
backupLabel = ` -> backup-${result.num}.md`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Save state
|
|
116
|
+
writeState(state);
|
|
117
|
+
|
|
118
|
+
// --- Build status line ---
|
|
119
|
+
// Format: [!] Opus 4.6 | 65k/200k | 32% used | 51% free -> backup-3.md
|
|
120
|
+
const shortModel = modelName
|
|
121
|
+
.replace(/^claude-/, '')
|
|
122
|
+
.replace(/-\d{8}$/, '')
|
|
123
|
+
.replace(/-/g, ' ')
|
|
124
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
125
|
+
|
|
126
|
+
const warn = freePercent < 15 ? '[!] ' : '';
|
|
127
|
+
const statusLine = `${warn}${shortModel} | ${usedK}k/${windowK}k | ${usedPercent}% used | ${freeDisplay}% free${backupLabel}`;
|
|
128
|
+
|
|
129
|
+
process.stdout.write(statusLine);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().catch(() => {
|
|
133
|
+
process.stdout.write('[!] Monitor error');
|
|
134
|
+
});
|