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 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
@@ -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
+ }
@@ -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
+ });