@vitorcen/context-resume 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) 2025 vitorcen
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,44 @@
1
+ # Context Resume CLI
2
+
3
+ Context Resume lets you quickly resume work across Claude Code (`~/.claude`) and Codex CLI (`~/.codex`) by listing recent sessions for the current working directory, showing a detailed prompt history preview, and printing a ready-to-use bilingual (English/Chinese) resume prompt.
4
+
5
+ ## Features
6
+ - **Dual-Panel View**: Split view for Claude and Codex sessions; use `TAB` to switch.
7
+ - **Detailed Preview**: Shows a list of user prompts from the selected session (truncated to 50 chars) at the top.
8
+ - **Configurable Limit**: Use `-n <count>` to control how many sessions to load per source.
9
+ - **Bilingual Output**: Prints both English and Chinese prompts pointing to the session file.
10
+ - **Privacy First**: Works entirely locally; no network calls.
11
+
12
+ ## Requirements
13
+ - Node.js 18+ (tested with ESM build).
14
+
15
+ ## Installation
16
+ ```bash
17
+ npm install
18
+ npm run build
19
+ npm link # optional, to install the global `context` command
20
+ ```
21
+
22
+ ## Usage
23
+ From any project directory you want to resume:
24
+ ```bash
25
+ context # Load default 10 sessions per source
26
+ context -n 20 # Load 20 sessions per source
27
+ ```
28
+ - Use **TAB** to switch between Claude and Codex panels.
29
+ - Use **Arrow Keys** to select a session.
30
+ - Preview at the top shows the sequence of user prompts.
31
+ - Press **Enter** to print the resume prompts (with absolute paths) and exit.
32
+
33
+ ## How it works
34
+ - **Claude**: Reads `~/.claude/projects/<encoded-path>/*.jsonl`.
35
+ - **Codex**: Scans `~/.codex/sessions/**/*.jsonl`, filters by `cwd` metadata.
36
+ - **Prompt Extraction**: Parses the session files to extract user inputs for the preview, giving you a quick summary of "what was I doing?".
37
+
38
+ ## Project layout
39
+ - `src/index.tsx` – Entry point, handles CLI arguments.
40
+ - `src/ui/app.tsx` – Ink UI with split panels and preview.
41
+ - `src/adapters/index.ts` – File parsers and prompt extractors.
42
+
43
+ ## Limitations
44
+ - Codex scanning involves globbing which might be slow on very large histories.
@@ -0,0 +1,170 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import os from 'os';
5
+ // --- Claude Adapter ---
6
+ function getClaudeEncodedPath(projectPath) {
7
+ // Claude encodes paths by replacing / and . with -
8
+ // e.g. /home/user/project -> -home-user-project
9
+ // e.g. /path/v1.0 -> -path-v1-0
10
+ return projectPath.replace(/[\/\.]/g, '-');
11
+ }
12
+ export async function getClaudeSessions(cwd, limit = 10) {
13
+ const homeDir = os.homedir();
14
+ const encodedPath = getClaudeEncodedPath(cwd);
15
+ const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
16
+ if (!fs.existsSync(projectDir)) {
17
+ return [];
18
+ }
19
+ const files = glob.sync('*.jsonl', { cwd: projectDir });
20
+ // Sort by modification time (descending)
21
+ const sortedFiles = files.map((file) => {
22
+ const filePath = path.join(projectDir, file);
23
+ const stats = fs.statSync(filePath);
24
+ return { file, mtime: stats.mtimeMs, filePath };
25
+ })
26
+ .sort((a, b) => b.mtime - a.mtime)
27
+ .slice(0, limit);
28
+ const sessions = [];
29
+ for (const { filePath, file } of sortedFiles) {
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ const lines = content.split('\n').filter(line => line.trim() !== '');
32
+ if (lines.length === 0)
33
+ continue;
34
+ let firstUserMessage = 'New Session';
35
+ let fullContent = '';
36
+ const userPrompts = [];
37
+ for (const line of lines) {
38
+ try {
39
+ const data = JSON.parse(line);
40
+ // Try to find the first user message for the title
41
+ if (data.type === 'user' && data.message?.content) {
42
+ const text = typeof data.message.content === 'string'
43
+ ? data.message.content
44
+ : (Array.isArray(data.message.content) ? data.message.content.map((c) => c.text || '').join(' ') : '');
45
+ userPrompts.push(text);
46
+ if (firstUserMessage === 'New Session') {
47
+ firstUserMessage = text;
48
+ }
49
+ }
50
+ // Accumulate content for preview (simplified)
51
+ if (data.type === 'user' && data.message?.content) {
52
+ const text = typeof data.message.content === 'string' ? data.message.content : 'User Input...';
53
+ fullContent += `User: ${text}\n`;
54
+ }
55
+ else if (data.message?.content && data.type !== 'user') { // Assistant or other
56
+ const text = typeof data.message.content === 'string' ? data.message.content : 'Assistant Output...';
57
+ fullContent += `Assistant: ${text}\n`;
58
+ }
59
+ else if (data.display) {
60
+ fullContent += `System: ${data.display}\n`;
61
+ }
62
+ }
63
+ catch (e) {
64
+ // ignore parse errors
65
+ }
66
+ }
67
+ // Create preview: Start ... End
68
+ const preview = createPreview(fullContent);
69
+ const stats = fs.statSync(filePath);
70
+ sessions.push({
71
+ id: path.basename(file, '.jsonl'),
72
+ title: firstUserMessage.slice(0, 50) + (firstUserMessage.length > 50 ? '...' : ''),
73
+ preview,
74
+ userPrompts,
75
+ timestamp: stats.mtimeMs,
76
+ source: 'claude',
77
+ path: filePath
78
+ });
79
+ }
80
+ return sessions;
81
+ }
82
+ // --- Codex Adapter ---
83
+ export async function getCodexSessions(cwd, limit = 10) {
84
+ const homeDir = os.homedir();
85
+ const sessionsDir = path.join(homeDir, '.codex', 'sessions');
86
+ if (!fs.existsSync(sessionsDir)) {
87
+ return [];
88
+ }
89
+ // We need to search deeply because of the date structure YYYY/MM/DD
90
+ // Optimization: Start searching from current year/month/day backwards could be better but glob is easier for MVP
91
+ // To avoid scanning ALL history, maybe we limit glob depth or just look at recent folders?
92
+ // For now, let's just glob all .jsonl and filter by CWD. In production, this should be optimized.
93
+ // Actually, reading all files to check CWD is slow.
94
+ // Strategy: Walk directories from today backwards?
95
+ // Let's use a simpler approach: glob all jsonl in the last 3 levels of directories
96
+ // Note: This might be slow if history is huge.
97
+ const files = glob.sync('**/*.jsonl', { cwd: sessionsDir, absolute: true });
98
+ // Filter by CWD and sort by time
99
+ const relevantFiles = [];
100
+ for (const filePath of files) {
101
+ // We need to peek at the first line to check CWD
102
+ try {
103
+ const fd = fs.openSync(filePath, 'r');
104
+ const buffer = Buffer.alloc(4096); // Read first 4KB
105
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
106
+ fs.closeSync(fd);
107
+ const chunk = buffer.toString('utf-8', 0, bytesRead);
108
+ const firstLine = chunk.split('\n')[0];
109
+ if (firstLine) {
110
+ const meta = JSON.parse(firstLine);
111
+ if (meta.type === 'session_meta' && meta.payload?.cwd === cwd) {
112
+ const stats = fs.statSync(filePath);
113
+ relevantFiles.push({ filePath, mtime: stats.mtimeMs });
114
+ }
115
+ }
116
+ }
117
+ catch (e) {
118
+ // ignore
119
+ }
120
+ }
121
+ const sortedFiles = relevantFiles
122
+ .sort((a, b) => b.mtime - a.mtime)
123
+ .slice(0, limit);
124
+ const sessions = [];
125
+ for (const { filePath, mtime } of sortedFiles) {
126
+ const content = fs.readFileSync(filePath, 'utf-8');
127
+ const lines = content.split('\n').filter(line => line.trim() !== '');
128
+ let firstUserMessage = 'New Session';
129
+ let fullContent = '';
130
+ const userPrompts = [];
131
+ for (const line of lines) {
132
+ try {
133
+ const data = JSON.parse(line);
134
+ // Codex User Input
135
+ if (data.type === 'response_item' && data.payload?.type === 'message' && data.payload.role === 'user') {
136
+ const contentArr = data.payload.content || [];
137
+ const text = contentArr.find((c) => c.type === 'input_text')?.text || '';
138
+ userPrompts.push(text);
139
+ if (firstUserMessage === 'New Session' && text) {
140
+ firstUserMessage = text;
141
+ }
142
+ fullContent += `User: ${text}\n`;
143
+ }
144
+ // Codex Assistant Response (simplified logic)
145
+ // Codex format is a bit complex with chunks, we might just grab user inputs for now or simple responses if easily identifiable
146
+ }
147
+ catch (e) {
148
+ // ignore
149
+ }
150
+ }
151
+ const preview = createPreview(fullContent);
152
+ sessions.push({
153
+ id: path.basename(filePath, '.jsonl'),
154
+ title: firstUserMessage.slice(0, 50) + (firstUserMessage.length > 50 ? '...' : ''),
155
+ preview,
156
+ userPrompts,
157
+ timestamp: mtime,
158
+ source: 'codex',
159
+ path: filePath
160
+ });
161
+ }
162
+ return sessions;
163
+ }
164
+ function createPreview(content, maxLength = 500) {
165
+ if (content.length <= maxLength)
166
+ return content;
167
+ const start = content.slice(0, maxLength / 2);
168
+ const end = content.slice(content.length - (maxLength / 2));
169
+ return `${start}\n\n... [${content.length - maxLength} characters omitted] ...\n\n${end}`;
170
+ }
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Command } from 'commander';
4
+ import { render } from 'ink';
5
+ import App from './ui/app.js';
6
+ const program = new Command();
7
+ program
8
+ .name('context')
9
+ .description('Context Resume CLI')
10
+ .version('1.0.0', '-v, --version');
11
+ program
12
+ .option('-n, --number <count>', 'Number of sessions to show per source (claude/codex)', '10')
13
+ .action(async (options) => {
14
+ const cwd = process.cwd();
15
+ const limit = parseInt(options.number, 10) || 10;
16
+ render(_jsx(App, { cwd: cwd, limit: limit }));
17
+ });
18
+ program.parse(process.argv);
package/dist/ui/app.js ADDED
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import SelectInput from 'ink-select-input';
5
+ import { getClaudeSessions, getCodexSessions } from '../adapters/index.js';
6
+ const App = ({ cwd, limit = 10 }) => {
7
+ const [claudeItems, setClaudeItems] = useState([]);
8
+ const [codexItems, setCodexItems] = useState([]);
9
+ const [activePanel, setActivePanel] = useState('claude');
10
+ const [activeClaudeItem, setActiveClaudeItem] = useState(null);
11
+ const [activeCodexItem, setActiveCodexItem] = useState(null);
12
+ const { exit } = useApp();
13
+ // Tab switching and Arrow Navigation
14
+ useInput((input, key) => {
15
+ if (key.tab) {
16
+ setActivePanel(prev => prev === 'claude' ? 'codex' : 'claude');
17
+ }
18
+ if (key.leftArrow && activePanel === 'codex') {
19
+ setActivePanel('claude');
20
+ }
21
+ if (key.rightArrow && activePanel === 'claude') {
22
+ setActivePanel('codex');
23
+ }
24
+ });
25
+ useEffect(() => {
26
+ const loadSessions = async () => {
27
+ const [claudeSessions, codexSessions] = await Promise.all([
28
+ getClaudeSessions(cwd, limit),
29
+ getCodexSessions(cwd, limit)
30
+ ]);
31
+ // Sort by timestamp desc
32
+ const sortFn = (a, b) => b.timestamp - a.timestamp;
33
+ const cItems = claudeSessions.sort(sortFn).map(s => ({
34
+ label: `${s.title} (${new Date(s.timestamp).toLocaleDateString()})`,
35
+ value: s.id,
36
+ session: s
37
+ }));
38
+ const cxItems = codexSessions.sort(sortFn).map(s => ({
39
+ label: `${s.title} (${new Date(s.timestamp).toLocaleDateString()})`,
40
+ value: s.id,
41
+ session: s
42
+ }));
43
+ setClaudeItems(cItems);
44
+ setCodexItems(cxItems);
45
+ if (cItems.length > 0)
46
+ setActiveClaudeItem(cItems[0]);
47
+ if (cxItems.length > 0)
48
+ setActiveCodexItem(cxItems[0]);
49
+ };
50
+ loadSessions();
51
+ }, [cwd, limit]);
52
+ const handleSelect = (item) => {
53
+ const selectedItem = item;
54
+ const englishPrompt = `Here's a context file ${selectedItem.session.path} from the user's previous operations. Analyze what the user was doing. Then use TodoWrite to list what might be incomplete, and what needs to be done next (if mentioned in the context), otherwise wait for user instructions.`;
55
+ const chinesePrompt = `这里有份上下文 ${selectedItem.session.path} ,是用户曾经的操作。你分析下用户在做什么。然后用TodoWrite列出可能没做完的事情,和接下来要的事情(如果上下文中有提到),如果没有就等待用户指令。`;
56
+ const output = `\n\n${englishPrompt}\n\n${chinesePrompt}\n\n`;
57
+ process.stdout.write(output);
58
+ exit();
59
+ };
60
+ const handleHighlightClaude = (item) => setActiveClaudeItem(item);
61
+ const handleHighlightCodex = (item) => setActiveCodexItem(item);
62
+ // Get current preview prompts
63
+ const currentItem = activePanel === 'claude' ? activeClaudeItem : activeCodexItem;
64
+ // Filter out empty prompts
65
+ const prompts = (currentItem?.session.userPrompts || []).filter(p => p && p.trim().length > 0);
66
+ // Truncate prompts logic
67
+ const previewText = prompts.map((p, i) => {
68
+ const clean = p.replace(/\n/g, ' ').trim();
69
+ const truncated = clean.length > 50 ? clean.slice(0, 50) + '...' : clean;
70
+ return `${i + 1}. ${truncated}`;
71
+ }).join('\n');
72
+ return (_jsxs(Box, { flexDirection: "column", height: 35, children: [_jsxs(Box, { height: 12, borderStyle: "single", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, underline: true, children: "Preview (User Prompts)" }), _jsx(Text, { children: previewText || 'Select a session to view prompts' })] }), _jsxs(Box, { flexDirection: "row", height: 20, children: [_jsxs(Box, { width: "50%", borderStyle: activePanel === 'claude' ? 'double' : 'single', flexDirection: "column", borderColor: activePanel === 'claude' ? 'green' : 'white', children: [_jsx(Text, { bold: true, underline: true, color: activePanel === 'claude' ? 'green' : 'white', children: "Claude Sessions" }), claudeItems.length === 0 ? (_jsx(Text, { children: "No sessions found." })) : (_jsx(SelectInput, { items: claudeItems, onSelect: handleSelect, onHighlight: handleHighlightClaude, isFocused: activePanel === 'claude' }))] }), _jsxs(Box, { width: "50%", borderStyle: activePanel === 'codex' ? 'double' : 'single', flexDirection: "column", borderColor: activePanel === 'codex' ? 'green' : 'white', children: [_jsx(Text, { bold: true, underline: true, color: activePanel === 'codex' ? 'green' : 'white', children: "Codex Sessions" }), codexItems.length === 0 ? (_jsx(Text, { children: "No sessions found." })) : (_jsx(SelectInput, { items: codexItems, onSelect: handleSelect, onHighlight: handleHighlightCodex, isFocused: activePanel === 'codex' }))] })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: "TAB/Arrows: Switch Panel | ENTER: Select | ESC: Exit" }) })] }));
73
+ };
74
+ export default App;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@vitorcen/context-resume",
3
+ "version": "1.0.0",
4
+ "description": "Context Resume CLI - Browse and mutually restore the conversation history of Claude Code and Codex",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "context": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "tsc && node dist/index.js",
13
+ "test": "echo \"Error: no test specified\" && exit 1",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "claude",
23
+ "codex",
24
+ "cli",
25
+ "context",
26
+ "history",
27
+ "resume",
28
+ "conversation",
29
+ "terminal"
30
+ ],
31
+ "author": "vitorcen",
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^14.0.2",
38
+ "date-fns": "^4.1.0",
39
+ "glob": "^13.0.0",
40
+ "ink": "^6.5.1",
41
+ "ink-box": "^1.0.0",
42
+ "ink-select-input": "^6.2.0",
43
+ "react": "^19.2.1"
44
+ },
45
+ "devDependencies": {
46
+ "@types/glob": "^8.1.0",
47
+ "@types/node": "^24.10.1",
48
+ "@types/react": "^19.2.7",
49
+ "ts-node": "^10.9.2",
50
+ "typescript": "^5.9.3"
51
+ }
52
+ }