@plexor-dev/claude-code-plugin 0.1.0-beta.16 → 0.1.0-beta.17

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.
@@ -12,6 +12,19 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const crypto = require('crypto');
16
+
17
+ /**
18
+ * Generate a unique request ID for tracking.
19
+ * Format: pass_<timestamp>_<random> for passthrough requests
20
+ * This ensures every request can be tracked, even passthroughs.
21
+ * Issue #701: Missing request_id caused response tracking to fail.
22
+ */
23
+ function generateRequestId(prefix = 'pass') {
24
+ const timestamp = Date.now().toString(36);
25
+ const random = crypto.randomBytes(4).toString('hex');
26
+ return `${prefix}_${timestamp}_${random}`;
27
+ }
15
28
 
16
29
  // Try to load lib modules, fall back to inline implementations
17
30
  let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
@@ -162,6 +175,8 @@ async function main() {
162
175
  const baseUrl = process.env.ANTHROPIC_BASE_URL || '';
163
176
  if (baseUrl.includes('plexor')) {
164
177
  logger.debug('Plexor proxy detected via ANTHROPIC_BASE_URL, passing through');
178
+ // Note: Don't add _plexor metadata here - the proxy will add its own
179
+ // This passthrough is transparent to avoid double-processing
165
180
  return output(request);
166
181
  }
167
182
 
@@ -175,6 +190,7 @@ async function main() {
175
190
  ...request,
176
191
  plexor_cwd: process.cwd(),
177
192
  _plexor: {
193
+ request_id: generateRequestId('slash'), // Issue #701: Add request_id for tracking
178
194
  source: 'passthrough_slash_command',
179
195
  reason: 'slash_command_detected',
180
196
  cwd: process.cwd(),
@@ -192,6 +208,7 @@ async function main() {
192
208
  ...request,
193
209
  plexor_cwd: process.cwd(),
194
210
  _plexor: {
211
+ request_id: generateRequestId('cli'), // Issue #701: Add request_id for tracking
195
212
  source: 'passthrough_cli',
196
213
  reason: 'cli_tool_execution_detected',
197
214
  cwd: process.cwd(),
@@ -209,6 +226,7 @@ async function main() {
209
226
  ...request,
210
227
  plexor_cwd: process.cwd(),
211
228
  _plexor: {
229
+ request_id: generateRequestId('agent'), // Issue #701: Add request_id for tracking
212
230
  source: 'passthrough_agentic',
213
231
  reason: 'tool_use_detected',
214
232
  cwd: process.cwd(),
@@ -221,12 +239,30 @@ async function main() {
221
239
 
222
240
  if (!settings.enabled) {
223
241
  logger.debug('Plexor disabled, passing through');
224
- return output(request);
242
+ session.recordPassthrough();
243
+ return output({
244
+ ...request,
245
+ _plexor: {
246
+ request_id: generateRequestId('disabled'), // Issue #701: Add request_id for tracking
247
+ source: 'passthrough_disabled',
248
+ reason: 'plexor_disabled',
249
+ latency_ms: Date.now() - startTime
250
+ }
251
+ });
225
252
  }
226
253
 
227
254
  if (!settings.apiKey) {
228
255
  logger.info('Not authenticated. Run /plexor-login to enable optimization.');
229
- return output(request);
256
+ session.recordPassthrough();
257
+ return output({
258
+ ...request,
259
+ _plexor: {
260
+ request_id: generateRequestId('noauth'), // Issue #701: Add request_id for tracking
261
+ source: 'passthrough_no_auth',
262
+ reason: 'not_authenticated',
263
+ latency_ms: Date.now() - startTime
264
+ }
265
+ });
230
266
  }
231
267
 
232
268
  const client = new PlexorClient({
@@ -248,6 +284,7 @@ async function main() {
248
284
  return output({
249
285
  ...request,
250
286
  _plexor: {
287
+ request_id: generateRequestId('cache'), // Issue #701: Add request_id for tracking
251
288
  source: 'local_cache',
252
289
  latency_ms: Date.now() - startTime
253
290
  }
@@ -312,11 +349,15 @@ async function main() {
312
349
  logger.error(`[Plexor] Error: ${error.message}`);
313
350
  logger.debug(error.stack);
314
351
 
352
+ const errorRequestId = generateRequestId('error'); // Issue #701: Add request_id for tracking
353
+
315
354
  // Use already-parsed request if available, otherwise pass through raw
316
355
  if (request) {
356
+ session.recordPassthrough();
317
357
  return output({
318
358
  ...request,
319
359
  _plexor: {
360
+ request_id: errorRequestId,
320
361
  error: error.message,
321
362
  source: 'passthrough_error'
322
363
  }
@@ -325,9 +366,11 @@ async function main() {
325
366
  // Try to parse the input we already read
326
367
  try {
327
368
  const req = JSON.parse(input);
369
+ session.recordPassthrough();
328
370
  return output({
329
371
  ...req,
330
372
  _plexor: {
373
+ request_id: errorRequestId,
331
374
  error: error.message,
332
375
  source: 'passthrough_error'
333
376
  }
@@ -476,11 +519,10 @@ function isAgenticRequest(request) {
476
519
  }
477
520
  }
478
521
 
479
- // Check for multi-turn conversations (likely agentic)
480
- const assistantMessages = messages.filter(m => m.role === 'assistant');
481
- if (assistantMessages.length > 2) {
482
- return true;
483
- }
522
+ // NOTE: Removed multi-turn check (Issue #701)
523
+ // Multi-turn conversations WITHOUT tool use are NOT agentic
524
+ // The old check caused normal conversations to be marked as agentic
525
+ // after just 2 assistant messages, leading to infinite loops
484
526
 
485
527
  return false;
486
528
  }
@@ -6,6 +6,10 @@
6
6
  * This script runs after the LLM response is received.
7
7
  * It tracks response metrics for analytics and updates session stats.
8
8
  *
9
+ * Issue #701 Fix: Now tracks ALL responses including passthroughs.
10
+ * Previously, responses without request_id would exit early without tracking,
11
+ * leading to "0 tokens" display and potential context issues.
12
+ *
9
13
  * Input: JSON object with response content, tokens used, etc.
10
14
  * Output: Passthrough (no modifications)
11
15
  */
@@ -13,17 +17,22 @@
13
17
  const path = require('path');
14
18
 
15
19
  // Use lib modules
16
- let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
20
+ let ConfigManager, SessionManager, LocalCache, Logger, ServerSync;
17
21
  try {
18
22
  ConfigManager = require('../lib/config');
19
23
  SessionManager = require('../lib/session');
20
24
  LocalCache = require('../lib/cache');
21
25
  Logger = require('../lib/logger');
22
- PlexorClient = require('../lib/plexor-client');
26
+ // Issue #701: Phase 2 - Server sync for persistent session state
27
+ const serverSyncModule = require('../lib/server-sync');
28
+ ServerSync = serverSyncModule.getServerSync;
23
29
  } catch {
24
30
  // Fallback inline implementations if lib not found
25
31
  const fs = require('fs');
26
32
  const CONFIG_PATH = path.join(process.env.HOME || '', '.plexor', 'config.json');
33
+ const SESSION_PATH = path.join(process.env.HOME || '', '.plexor', 'session.json');
34
+ const PLEXOR_DIR = path.join(process.env.HOME || '', '.plexor');
35
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
27
36
 
28
37
  ConfigManager = class {
29
38
  async load() {
@@ -40,9 +49,84 @@ try {
40
49
  }
41
50
  };
42
51
 
52
+ // Inline SessionManager for fallback (Issue #701: proper tracking)
43
53
  SessionManager = class {
44
- recordOptimization() {}
45
- recordPassthrough() {}
54
+ constructor() {
55
+ this.sessionPath = SESSION_PATH;
56
+ }
57
+
58
+ load() {
59
+ try {
60
+ const data = fs.readFileSync(this.sessionPath, 'utf8');
61
+ const session = JSON.parse(data);
62
+ if (Date.now() - session.last_activity > SESSION_TIMEOUT_MS) {
63
+ return this.createNew();
64
+ }
65
+ return session;
66
+ } catch {
67
+ return this.createNew();
68
+ }
69
+ }
70
+
71
+ createNew() {
72
+ const session = {
73
+ session_id: `session_${Date.now()}`,
74
+ started_at: new Date().toISOString(),
75
+ last_activity: Date.now(),
76
+ requests: 0, optimizations: 0, cache_hits: 0, passthroughs: 0,
77
+ original_tokens: 0, optimized_tokens: 0, tokens_saved: 0,
78
+ output_tokens: 0, // Issue #701: Track output tokens
79
+ baseline_cost: 0, actual_cost: 0, cost_saved: 0
80
+ };
81
+ this.save(session);
82
+ return session;
83
+ }
84
+
85
+ save(session) {
86
+ try {
87
+ if (!fs.existsSync(PLEXOR_DIR)) {
88
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true });
89
+ }
90
+ session.last_activity = Date.now();
91
+ fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
92
+ } catch {}
93
+ }
94
+
95
+ recordOptimization(result) {
96
+ const s = this.load();
97
+ s.requests++;
98
+ s.optimizations++;
99
+ s.original_tokens += result.original_tokens || 0;
100
+ s.optimized_tokens += result.optimized_tokens || 0;
101
+ s.tokens_saved += result.tokens_saved || 0;
102
+ s.output_tokens += result.output_tokens || 0; // Issue #701
103
+ s.baseline_cost += result.baseline_cost || 0;
104
+ s.actual_cost += result.estimated_cost || result.actual_cost || 0;
105
+ s.cost_saved += (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0);
106
+ this.save(s);
107
+ return s;
108
+ }
109
+
110
+ recordCacheHit() {
111
+ const s = this.load();
112
+ s.requests++;
113
+ s.cache_hits++;
114
+ this.save(s);
115
+ return s;
116
+ }
117
+
118
+ recordPassthrough(outputTokens = 0) {
119
+ const s = this.load();
120
+ s.requests++;
121
+ s.passthroughs = (s.passthroughs || 0) + 1;
122
+ s.output_tokens += outputTokens; // Issue #701: Track output even for passthroughs
123
+ this.save(s);
124
+ return s;
125
+ }
126
+
127
+ getStats() {
128
+ return this.load();
129
+ }
46
130
  };
47
131
 
48
132
  LocalCache = class {
@@ -50,10 +134,28 @@ try {
50
134
  };
51
135
 
52
136
  Logger = class {
53
- info(msg) { console.error(msg); }
54
- error(msg) { console.error(`[ERROR] ${msg}`); }
55
- debug() {}
137
+ constructor(name) { this.name = name; }
138
+ info(msg, data) {
139
+ if (data) {
140
+ console.error(`[${this.name}] ${msg}`, JSON.stringify(data));
141
+ } else {
142
+ console.error(`[${this.name}] ${msg}`);
143
+ }
144
+ }
145
+ error(msg) { console.error(`[${this.name}] [ERROR] ${msg}`); }
146
+ debug(msg) {
147
+ if (process.env.PLEXOR_DEBUG) {
148
+ console.error(`[${this.name}] [DEBUG] ${msg}`);
149
+ }
150
+ }
56
151
  };
152
+
153
+ // Issue #701: Fallback ServerSync that does nothing
154
+ ServerSync = () => ({
155
+ syncSession: async () => ({ synced: false, message: 'Server sync not available' }),
156
+ scheduleSync: () => {},
157
+ needsSync: () => false
158
+ });
57
159
  }
58
160
 
59
161
  const logger = new Logger('track-response');
@@ -61,69 +163,185 @@ const config = new ConfigManager();
61
163
  const cache = new LocalCache();
62
164
  const session = new SessionManager();
63
165
 
166
+ // Issue #701: Phase 2 - Initialize server sync (lazy, initialized on first use)
167
+ let serverSync = null;
168
+
169
+ async function getServerSync() {
170
+ if (serverSync) return serverSync;
171
+
172
+ try {
173
+ const settings = await config.load();
174
+ if (settings.apiKey && settings.enabled) {
175
+ serverSync = ServerSync({
176
+ apiKey: settings.apiKey,
177
+ baseUrl: settings.apiUrl || 'https://api.plexor.dev',
178
+ enabled: settings.serverSyncEnabled !== false
179
+ });
180
+ } else {
181
+ serverSync = ServerSync({ enabled: false });
182
+ }
183
+ } catch {
184
+ serverSync = ServerSync({ enabled: false });
185
+ }
186
+ return serverSync;
187
+ }
188
+
64
189
  async function main() {
190
+ let response;
191
+ let input;
192
+
65
193
  try {
66
- const input = await readStdin();
67
- const response = JSON.parse(input);
194
+ input = await readStdin();
195
+ response = JSON.parse(input);
68
196
 
69
- const settings = await config.load();
197
+ // Calculate output tokens for ALL responses (Issue #701)
198
+ const outputTokens = estimateOutputTokens(response);
70
199
 
71
- // If Plexor is disabled or no API key, just pass through
72
- if (!settings.enabled || !settings.apiKey) {
73
- return output(response);
200
+ // Get Plexor metadata if present
201
+ const plexorMeta = response._plexor;
202
+
203
+ // Issue #701: Track ALL responses, not just when enabled
204
+ // This ensures session stats are always accurate
205
+ if (plexorMeta) {
206
+ await trackResponseWithMetadata(plexorMeta, outputTokens);
207
+ } else {
208
+ // No Plexor metadata - still track the output tokens
209
+ session.recordPassthrough(outputTokens);
210
+ logger.debug('Response tracked without Plexor metadata');
74
211
  }
75
212
 
76
- // Check if this response has Plexor metadata
77
- const plexorMeta = response._plexor;
78
- if (!plexorMeta || !plexorMeta.request_id) {
79
- // No Plexor metadata, but still record the request
80
- session.recordPassthrough();
213
+ // Issue #701: Phase 2 - Schedule server sync (non-blocking)
214
+ // This syncs local session stats to the server for persistence
215
+ try {
216
+ const sync = await getServerSync();
217
+ const localStats = session.getStats();
218
+ sync.scheduleSync(localStats);
219
+ } catch (syncError) {
220
+ // Server sync failures should not affect response passthrough
221
+ logger.debug(`Server sync scheduling failed: ${syncError.message}`);
222
+ }
223
+
224
+ // Pass through unchanged
225
+ return output(response);
226
+
227
+ } catch (error) {
228
+ logger.error(`Tracking error: ${error.message}`);
229
+
230
+ // On any error, try to pass through the response unchanged
231
+ if (response) {
81
232
  return output(response);
233
+ } else if (input) {
234
+ try {
235
+ return output(JSON.parse(input));
236
+ } catch {
237
+ process.exit(1);
238
+ }
239
+ } else {
240
+ process.exit(1);
82
241
  }
242
+ }
243
+ }
83
244
 
84
- // Get stored metadata for this request
85
- const metadata = await cache.getMetadata(plexorMeta.request_id);
245
+ /**
246
+ * Track response using Plexor metadata.
247
+ * Issue #701: Handles ALL source types, not just plexor_api.
248
+ */
249
+ async function trackResponseWithMetadata(plexorMeta, outputTokens) {
250
+ const source = plexorMeta.source || 'unknown';
251
+ const requestId = plexorMeta.request_id;
86
252
 
87
- // Calculate output tokens (approximate)
88
- const outputTokens = estimateTokens(response.content || '');
253
+ logger.debug(`Tracking response: source=${source}, request_id=${requestId}`);
89
254
 
90
- // Update session stats with response data
91
- if (plexorMeta.source === 'plexor_api') {
255
+ switch (source) {
256
+ case 'plexor_api':
257
+ // Full optimization was applied
92
258
  session.recordOptimization({
93
259
  original_tokens: plexorMeta.original_tokens || 0,
94
260
  optimized_tokens: plexorMeta.optimized_tokens || 0,
95
261
  tokens_saved: plexorMeta.tokens_saved || 0,
96
262
  baseline_cost: plexorMeta.baseline_cost || 0,
97
- estimated_cost: plexorMeta.estimated_cost || 0
263
+ estimated_cost: plexorMeta.estimated_cost || 0,
264
+ output_tokens: outputTokens
98
265
  });
99
-
100
- logger.info('[Plexor] Response tracked', {
101
- request_id: plexorMeta.request_id,
266
+ logger.info('[Plexor] Optimized response tracked', {
267
+ request_id: requestId,
102
268
  input_tokens: plexorMeta.optimized_tokens,
103
269
  output_tokens: outputTokens,
270
+ savings_percent: plexorMeta.savings_percent,
104
271
  provider: plexorMeta.recommended_provider
105
272
  });
106
- } else if (plexorMeta.source === 'local_cache') {
273
+ break;
274
+
275
+ case 'local_cache':
107
276
  session.recordCacheHit();
108
- logger.info('[Plexor] Cache hit recorded');
109
- } else {
110
- session.recordPassthrough();
111
- }
277
+ logger.info('[Plexor] Cache hit recorded', { request_id: requestId });
278
+ break;
112
279
 
113
- // Pass through unchanged
114
- return output(response);
280
+ case 'passthrough_agentic':
281
+ case 'passthrough_slash_command':
282
+ case 'passthrough_cli':
283
+ case 'passthrough_disabled':
284
+ case 'passthrough_no_auth':
285
+ case 'passthrough_error':
286
+ case 'passthrough':
287
+ // Passthrough - still track output tokens
288
+ session.recordPassthrough(outputTokens);
289
+ logger.debug(`Passthrough recorded: ${source}`, {
290
+ request_id: requestId,
291
+ reason: plexorMeta.reason,
292
+ output_tokens: outputTokens
293
+ });
294
+ break;
115
295
 
116
- } catch (error) {
117
- logger.error(`Tracking error: ${error.message}`);
296
+ default:
297
+ // Unknown source - treat as passthrough
298
+ session.recordPassthrough(outputTokens);
299
+ logger.debug(`Unknown source tracked as passthrough: ${source}`, {
300
+ request_id: requestId,
301
+ output_tokens: outputTokens
302
+ });
303
+ }
304
+ }
118
305
 
119
- // On any error, pass through unchanged
120
- try {
121
- const input = await readStdin();
122
- return output(JSON.parse(input));
123
- } catch {
124
- process.exit(1);
306
+ /**
307
+ * Estimate output tokens from response content.
308
+ * Handles both string and structured content formats.
309
+ */
310
+ function estimateOutputTokens(response) {
311
+ if (!response) return 0;
312
+
313
+ // Try to get content from various response formats
314
+ let text = '';
315
+
316
+ // Anthropic format: content array with text blocks
317
+ if (Array.isArray(response.content)) {
318
+ for (const block of response.content) {
319
+ if (block.type === 'text' && block.text) {
320
+ text += block.text;
321
+ } else if (block.type === 'tool_use') {
322
+ // Tool use blocks contribute to token count
323
+ text += JSON.stringify(block.input || {});
324
+ }
125
325
  }
126
326
  }
327
+ // Simple string content
328
+ else if (typeof response.content === 'string') {
329
+ text = response.content;
330
+ }
331
+ // OpenAI format: choices array
332
+ else if (response.choices && response.choices[0]) {
333
+ const choice = response.choices[0];
334
+ if (choice.message && choice.message.content) {
335
+ text = choice.message.content;
336
+ } else if (choice.text) {
337
+ text = choice.text;
338
+ }
339
+ }
340
+
341
+ if (!text) return 0;
342
+
343
+ // Approximate: ~4 characters per token (rough estimate)
344
+ return Math.max(1, Math.ceil(text.length / 4));
127
345
  }
128
346
 
129
347
  async function readStdin() {
@@ -152,12 +370,6 @@ function output(data) {
152
370
  process.exit(0);
153
371
  }
154
372
 
155
- function estimateTokens(text) {
156
- if (!text) return 0;
157
- // Approximate: ~4 characters per token
158
- return Math.max(1, Math.ceil(text.length / 4));
159
- }
160
-
161
373
  main().catch((error) => {
162
374
  console.error(`[Plexor] Fatal error: ${error.message}`);
163
375
  process.exit(1);
@@ -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 CHANGED
@@ -38,9 +38,11 @@ class SessionManager {
38
38
  requests: 0,
39
39
  optimizations: 0,
40
40
  cache_hits: 0,
41
+ passthroughs: 0, // Issue #701: Track passthroughs
41
42
  original_tokens: 0,
42
43
  optimized_tokens: 0,
43
44
  tokens_saved: 0,
45
+ output_tokens: 0, // Issue #701: Track output tokens
44
46
  baseline_cost: 0,
45
47
  actual_cost: 0,
46
48
  cost_saved: 0
@@ -71,6 +73,7 @@ class SessionManager {
71
73
  session.original_tokens += stats.original_tokens || 0;
72
74
  session.optimized_tokens += stats.optimized_tokens || 0;
73
75
  session.tokens_saved += stats.tokens_saved || 0;
76
+ session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0); // Issue #701
74
77
  session.baseline_cost += stats.baseline_cost || 0;
75
78
  session.actual_cost += stats.actual_cost || 0;
76
79
  session.cost_saved += stats.cost_saved || 0;
@@ -80,6 +83,12 @@ class SessionManager {
80
83
  session.cache_hits++;
81
84
  }
82
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
+
83
92
  this.save(session);
84
93
  return session;
85
94
  }
@@ -90,6 +99,7 @@ class SessionManager {
90
99
  original_tokens: result.original_tokens || 0,
91
100
  optimized_tokens: result.optimized_tokens || 0,
92
101
  tokens_saved: result.tokens_saved || 0,
102
+ output_tokens: result.output_tokens || 0, // Issue #701: Track output tokens
93
103
  baseline_cost: result.baseline_cost || 0,
94
104
  actual_cost: result.estimated_cost || result.actual_cost || 0,
95
105
  cost_saved: (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0)
@@ -100,8 +110,9 @@ class SessionManager {
100
110
  return this.recordRequest({ cache_hit: true });
101
111
  }
102
112
 
103
- recordPassthrough() {
104
- return this.recordRequest({});
113
+ recordPassthrough(outputTokens = 0) {
114
+ // Issue #701: Track passthroughs and their output tokens
115
+ return this.recordRequest({ passthrough: true, output_tokens: outputTokens });
105
116
  }
106
117
 
107
118
  getStats() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexor-dev/claude-code-plugin",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.17",
4
4
  "description": "LLM cost optimization plugin for Claude Code - Save up to 90% on AI costs",
5
5
  "main": "lib/constants.js",
6
6
  "scripts": {