@plexor-dev/claude-code-plugin-staging 0.1.0-beta.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Plexor API Client
3
+ *
4
+ * Handles communication with the Plexor optimization API.
5
+ */
6
+
7
+ const https = require('https');
8
+ const http = require('http');
9
+ const { URL } = require('url');
10
+ const { DEFAULT_API_URL, DEFAULT_TIMEOUT } = require('./constants');
11
+
12
+ class PlexorClient {
13
+ constructor(options = {}) {
14
+ this.apiKey = options.apiKey || '';
15
+ this.baseUrl = options.baseUrl || DEFAULT_API_URL;
16
+ this.timeout = options.timeout || DEFAULT_TIMEOUT;
17
+ }
18
+
19
+ async request(method, path, body = null) {
20
+ return new Promise((resolve, reject) => {
21
+ const url = new URL(path, this.baseUrl);
22
+ const isHttps = url.protocol === 'https:';
23
+ const lib = isHttps ? https : http;
24
+
25
+ const options = {
26
+ hostname: url.hostname,
27
+ port: url.port || (isHttps ? 443 : 80),
28
+ path: url.pathname + url.search,
29
+ method: method,
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ 'X-API-Key': this.apiKey,
33
+ 'X-Plexor-Key': this.apiKey,
34
+ 'User-Agent': 'plexor-claude-code-plugin/0.1.0-beta.22'
35
+ },
36
+ timeout: this.timeout
37
+ };
38
+
39
+ const req = lib.request(options, (res) => {
40
+ let data = '';
41
+ res.on('data', (chunk) => data += chunk);
42
+ res.on('end', () => {
43
+ try {
44
+ const json = JSON.parse(data);
45
+ if (res.statusCode >= 200 && res.statusCode < 300) {
46
+ resolve(json);
47
+ } else {
48
+ reject(new Error(json.message || `HTTP ${res.statusCode}`));
49
+ }
50
+ } catch {
51
+ reject(new Error(`Invalid JSON response: ${data.substring(0, 100)}`));
52
+ }
53
+ });
54
+ });
55
+
56
+ req.on('error', reject);
57
+ req.on('timeout', () => {
58
+ req.destroy();
59
+ reject(new Error('Request timeout'));
60
+ });
61
+
62
+ if (body) {
63
+ req.write(JSON.stringify(body));
64
+ }
65
+
66
+ req.end();
67
+ });
68
+ }
69
+
70
+ async optimize(params) {
71
+ try {
72
+ const result = await this.request('POST', '/v1/optimize', {
73
+ messages: params.messages,
74
+ model: params.model,
75
+ max_tokens: params.max_tokens,
76
+ task_hint: params.task_hint,
77
+ context: params.context
78
+ });
79
+
80
+ return {
81
+ request_id: result.request_id || `req_${Date.now()}`,
82
+ original_tokens: result.original_tokens || 0,
83
+ optimized_tokens: result.optimized_tokens || 0,
84
+ tokens_saved: result.tokens_saved || 0,
85
+ optimized_messages: result.optimized_messages || params.messages,
86
+ recommended_provider: result.recommended_provider || 'anthropic',
87
+ recommended_model: result.recommended_model || params.model,
88
+ estimated_cost: result.estimated_cost || 0,
89
+ baseline_cost: result.baseline_cost || 0
90
+ };
91
+ } catch (error) {
92
+ // Return passthrough on error
93
+ const tokens = Math.round(JSON.stringify(params.messages).length / 4);
94
+ return {
95
+ request_id: `req_${Date.now()}`,
96
+ original_tokens: tokens,
97
+ optimized_tokens: tokens,
98
+ tokens_saved: 0,
99
+ optimized_messages: params.messages,
100
+ recommended_provider: 'anthropic',
101
+ recommended_model: params.model,
102
+ estimated_cost: 0,
103
+ baseline_cost: 0,
104
+ error: error.message
105
+ };
106
+ }
107
+ }
108
+
109
+ async getUser() {
110
+ return this.request('GET', '/v1/user');
111
+ }
112
+
113
+ async getStats() {
114
+ return this.request('GET', '/v1/stats');
115
+ }
116
+
117
+ async trackResponse(requestId, metrics) {
118
+ return this.request('POST', `/v1/track/${requestId}`, metrics);
119
+ }
120
+ }
121
+
122
+ module.exports = PlexorClient;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Plexor Server Sync Module
3
+ *
4
+ * Issue #701: Syncs local plugin session state to the Plexor server.
5
+ * This ensures accurate token tracking even for passthrough requests.
6
+ *
7
+ * Phase 2 of the hypervisor architecture implementation.
8
+ */
9
+
10
+ const https = require('https');
11
+ const http = require('http');
12
+
13
+ class ServerSync {
14
+ constructor(options = {}) {
15
+ this.apiKey = options.apiKey;
16
+ this.baseUrl = options.baseUrl || 'https://api.plexor.dev';
17
+ this.timeout = options.timeout || 5000;
18
+ this.enabled = options.enabled !== false;
19
+ this.lastSyncTime = null;
20
+ this.syncInterval = options.syncInterval || 60000; // 1 minute default
21
+ this.pendingSync = null;
22
+ this.serverSessionId = null;
23
+ }
24
+
25
+ /**
26
+ * Sync local session stats to the server.
27
+ * Issue #701: Ensures server has accurate metrics even for passthroughs.
28
+ *
29
+ * @param {Object} localSession - Local session stats from SessionManager
30
+ * @returns {Promise<Object>} Server response with synced metrics
31
+ */
32
+ async syncSession(localSession) {
33
+ if (!this.enabled || !this.apiKey) {
34
+ return { synced: false, message: 'Server sync disabled or no API key' };
35
+ }
36
+
37
+ try {
38
+ const syncData = {
39
+ session_id: this.serverSessionId,
40
+ requests: localSession.requests || 0,
41
+ optimizations: localSession.optimizations || 0,
42
+ cache_hits: localSession.cache_hits || 0,
43
+ passthroughs: localSession.passthroughs || 0,
44
+ original_tokens: localSession.original_tokens || 0,
45
+ optimized_tokens: localSession.optimized_tokens || 0,
46
+ tokens_saved: localSession.tokens_saved || 0,
47
+ output_tokens: localSession.output_tokens || 0,
48
+ baseline_cost: localSession.baseline_cost || 0,
49
+ actual_cost: localSession.actual_cost || 0,
50
+ cost_saved: localSession.cost_saved || 0
51
+ };
52
+
53
+ const response = await this._postToServer('/v1/plugin/session/sync', syncData);
54
+
55
+ if (response.synced) {
56
+ this.serverSessionId = response.server_session_id;
57
+ this.lastSyncTime = Date.now();
58
+ }
59
+
60
+ return response;
61
+ } catch (error) {
62
+ // Don't throw - server sync failures shouldn't break the plugin
63
+ return {
64
+ synced: false,
65
+ message: `Server sync failed: ${error.message}`,
66
+ error: error.message
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get server-side session state.
73
+ *
74
+ * @param {string} sessionId - Session ID to query
75
+ * @returns {Promise<Object>} Server session state
76
+ */
77
+ async getServerSession(sessionId) {
78
+ if (!this.enabled || !this.apiKey) {
79
+ return { found: false, message: 'Server sync disabled or no API key' };
80
+ }
81
+
82
+ try {
83
+ return await this._getFromServer(`/v1/plugin/session/${sessionId}`);
84
+ } catch (error) {
85
+ return {
86
+ found: false,
87
+ message: `Failed to get server session: ${error.message}`
88
+ };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Schedule periodic sync (debounced).
94
+ *
95
+ * @param {Object} localSession - Local session stats
96
+ */
97
+ scheduleSync(localSession) {
98
+ // Debounce: don't sync more than once per interval
99
+ if (this.pendingSync) {
100
+ clearTimeout(this.pendingSync);
101
+ }
102
+
103
+ this.pendingSync = setTimeout(async () => {
104
+ await this.syncSession(localSession);
105
+ this.pendingSync = null;
106
+ }, 1000); // Sync 1 second after last request
107
+ }
108
+
109
+ /**
110
+ * POST data to the Plexor server.
111
+ *
112
+ * @param {string} path - API path
113
+ * @param {Object} data - Data to send
114
+ * @returns {Promise<Object>} Parsed JSON response
115
+ */
116
+ _postToServer(path, data) {
117
+ return new Promise((resolve, reject) => {
118
+ const url = new URL(path, this.baseUrl);
119
+ const isHttps = url.protocol === 'https:';
120
+ const client = isHttps ? https : http;
121
+
122
+ const options = {
123
+ hostname: url.hostname,
124
+ port: url.port || (isHttps ? 443 : 80),
125
+ path: url.pathname,
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${this.apiKey}`,
130
+ 'X-Plexor-Plugin-Version': '0.1.0-beta.5',
131
+ 'X-Plexor-Issue': '701'
132
+ },
133
+ timeout: this.timeout
134
+ };
135
+
136
+ const req = client.request(options, (res) => {
137
+ let body = '';
138
+ res.on('data', chunk => body += chunk);
139
+ res.on('end', () => {
140
+ try {
141
+ const parsed = JSON.parse(body);
142
+ resolve(parsed);
143
+ } catch (e) {
144
+ reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
145
+ }
146
+ });
147
+ });
148
+
149
+ req.on('error', reject);
150
+ req.on('timeout', () => {
151
+ req.destroy();
152
+ reject(new Error('Request timeout'));
153
+ });
154
+
155
+ req.write(JSON.stringify(data));
156
+ req.end();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * GET data from the Plexor server.
162
+ *
163
+ * @param {string} path - API path
164
+ * @returns {Promise<Object>} Parsed JSON response
165
+ */
166
+ _getFromServer(path) {
167
+ return new Promise((resolve, reject) => {
168
+ const url = new URL(path, this.baseUrl);
169
+ const isHttps = url.protocol === 'https:';
170
+ const client = isHttps ? https : http;
171
+
172
+ const options = {
173
+ hostname: url.hostname,
174
+ port: url.port || (isHttps ? 443 : 80),
175
+ path: url.pathname,
176
+ method: 'GET',
177
+ headers: {
178
+ 'Authorization': `Bearer ${this.apiKey}`,
179
+ 'X-Plexor-Plugin-Version': '0.1.0-beta.5'
180
+ },
181
+ timeout: this.timeout
182
+ };
183
+
184
+ const req = client.request(options, (res) => {
185
+ let body = '';
186
+ res.on('data', chunk => body += chunk);
187
+ res.on('end', () => {
188
+ try {
189
+ resolve(JSON.parse(body));
190
+ } catch (e) {
191
+ reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
192
+ }
193
+ });
194
+ });
195
+
196
+ req.on('error', reject);
197
+ req.on('timeout', () => {
198
+ req.destroy();
199
+ reject(new Error('Request timeout'));
200
+ });
201
+
202
+ req.end();
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Check if sync is needed based on time elapsed.
208
+ */
209
+ needsSync() {
210
+ if (!this.lastSyncTime) return true;
211
+ return Date.now() - this.lastSyncTime > this.syncInterval;
212
+ }
213
+ }
214
+
215
+ // Singleton instance
216
+ let _serverSync = null;
217
+
218
+ function getServerSync(options = {}) {
219
+ if (!_serverSync) {
220
+ _serverSync = new ServerSync(options);
221
+ } else if (options.apiKey && options.apiKey !== _serverSync.apiKey) {
222
+ // Update API key if changed
223
+ _serverSync.apiKey = options.apiKey;
224
+ _serverSync.baseUrl = options.baseUrl || _serverSync.baseUrl;
225
+ }
226
+ return _serverSync;
227
+ }
228
+
229
+ function resetServerSync() {
230
+ _serverSync = null;
231
+ }
232
+
233
+ module.exports = {
234
+ ServerSync,
235
+ getServerSync,
236
+ resetServerSync
237
+ };
package/lib/session.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Plexor Session Manager
3
+ *
4
+ * Tracks session statistics for the current Claude Code session.
5
+ * Session expires after 30 minutes of inactivity.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { SESSION_PATH, PLEXOR_DIR, SESSION_TIMEOUT_MS } = require('./constants');
11
+
12
+ class SessionManager {
13
+ constructor() {
14
+ this.sessionPath = SESSION_PATH;
15
+ }
16
+
17
+ load() {
18
+ try {
19
+ const data = fs.readFileSync(this.sessionPath, 'utf8');
20
+ const session = JSON.parse(data);
21
+
22
+ // Check if session has expired (30 min inactivity)
23
+ if (Date.now() - session.last_activity > SESSION_TIMEOUT_MS) {
24
+ return this.createNew();
25
+ }
26
+
27
+ return session;
28
+ } catch {
29
+ return this.createNew();
30
+ }
31
+ }
32
+
33
+ createNew() {
34
+ const session = {
35
+ session_id: `session_${Date.now()}`,
36
+ started_at: new Date().toISOString(),
37
+ last_activity: Date.now(),
38
+ requests: 0,
39
+ optimizations: 0,
40
+ cache_hits: 0,
41
+ passthroughs: 0, // Issue #701: Track passthroughs
42
+ original_tokens: 0,
43
+ optimized_tokens: 0,
44
+ tokens_saved: 0,
45
+ output_tokens: 0, // Issue #701: Track output tokens
46
+ baseline_cost: 0,
47
+ actual_cost: 0,
48
+ cost_saved: 0
49
+ };
50
+ this.save(session);
51
+ return session;
52
+ }
53
+
54
+ save(session) {
55
+ try {
56
+ if (!fs.existsSync(PLEXOR_DIR)) {
57
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true });
58
+ }
59
+ session.last_activity = Date.now();
60
+ fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ recordRequest(stats = {}) {
68
+ const session = this.load();
69
+ session.requests++;
70
+
71
+ if (stats.optimized) {
72
+ session.optimizations++;
73
+ session.original_tokens += stats.original_tokens || 0;
74
+ session.optimized_tokens += stats.optimized_tokens || 0;
75
+ session.tokens_saved += stats.tokens_saved || 0;
76
+ session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0); // Issue #701
77
+ session.baseline_cost += stats.baseline_cost || 0;
78
+ session.actual_cost += stats.actual_cost || 0;
79
+ session.cost_saved += stats.cost_saved || 0;
80
+ }
81
+
82
+ if (stats.cache_hit) {
83
+ session.cache_hits++;
84
+ }
85
+
86
+ // Issue #701: Track passthroughs and their output tokens
87
+ if (stats.passthrough) {
88
+ session.passthroughs = (session.passthroughs || 0) + 1;
89
+ session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0);
90
+ }
91
+
92
+ this.save(session);
93
+ return session;
94
+ }
95
+
96
+ recordOptimization(result) {
97
+ return this.recordRequest({
98
+ optimized: true,
99
+ original_tokens: result.original_tokens || 0,
100
+ optimized_tokens: result.optimized_tokens || 0,
101
+ tokens_saved: result.tokens_saved || 0,
102
+ output_tokens: result.output_tokens || 0, // Issue #701: Track output tokens
103
+ baseline_cost: result.baseline_cost || 0,
104
+ actual_cost: result.estimated_cost || result.actual_cost || 0,
105
+ cost_saved: (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0)
106
+ });
107
+ }
108
+
109
+ recordCacheHit() {
110
+ return this.recordRequest({ cache_hit: true });
111
+ }
112
+
113
+ recordPassthrough(outputTokens = 0) {
114
+ // Issue #701: Track passthroughs and their output tokens
115
+ return this.recordRequest({ passthrough: true, output_tokens: outputTokens });
116
+ }
117
+
118
+ getStats() {
119
+ const session = this.load();
120
+ const duration = Date.now() - new Date(session.started_at).getTime();
121
+
122
+ return {
123
+ ...session,
124
+ duration_ms: duration,
125
+ duration_formatted: this.formatDuration(duration),
126
+ tokens_saved_percent: session.original_tokens > 0
127
+ ? ((session.tokens_saved / session.original_tokens) * 100).toFixed(1)
128
+ : '0.0',
129
+ cost_saved_percent: session.baseline_cost > 0
130
+ ? ((session.cost_saved / session.baseline_cost) * 100).toFixed(1)
131
+ : '0.0'
132
+ };
133
+ }
134
+
135
+ formatDuration(ms) {
136
+ const minutes = Math.floor(ms / 60000);
137
+ if (minutes < 60) {
138
+ return `${minutes}m`;
139
+ }
140
+ const hours = Math.floor(minutes / 60);
141
+ const remainingMinutes = minutes % 60;
142
+ return `${hours}h ${remainingMinutes}m`;
143
+ }
144
+
145
+ isExpired() {
146
+ try {
147
+ const data = fs.readFileSync(this.sessionPath, 'utf8');
148
+ const session = JSON.parse(data);
149
+ return Date.now() - session.last_activity > SESSION_TIMEOUT_MS;
150
+ } catch {
151
+ return true;
152
+ }
153
+ }
154
+ }
155
+
156
+ module.exports = SessionManager;