@plexor-dev/claude-code-plugin 0.1.0-beta.15 → 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.
@@ -6,71 +6,342 @@
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
  */
12
16
 
13
- const PlexorClient = require('../lib/plexor-client');
14
- const ConfigManager = require('../lib/config');
15
- const LocalCache = require('../lib/cache');
16
- const Logger = require('../lib/logger');
17
+ const path = require('path');
18
+
19
+ // Use lib modules
20
+ let ConfigManager, SessionManager, LocalCache, Logger, ServerSync;
21
+ try {
22
+ ConfigManager = require('../lib/config');
23
+ SessionManager = require('../lib/session');
24
+ LocalCache = require('../lib/cache');
25
+ Logger = require('../lib/logger');
26
+ // Issue #701: Phase 2 - Server sync for persistent session state
27
+ const serverSyncModule = require('../lib/server-sync');
28
+ ServerSync = serverSyncModule.getServerSync;
29
+ } catch {
30
+ // Fallback inline implementations if lib not found
31
+ const fs = require('fs');
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;
36
+
37
+ ConfigManager = class {
38
+ async load() {
39
+ try {
40
+ const data = fs.readFileSync(CONFIG_PATH, 'utf8');
41
+ const cfg = JSON.parse(data);
42
+ return {
43
+ enabled: cfg.settings?.enabled ?? false,
44
+ apiKey: cfg.auth?.api_key
45
+ };
46
+ } catch {
47
+ return { enabled: false };
48
+ }
49
+ }
50
+ };
51
+
52
+ // Inline SessionManager for fallback (Issue #701: proper tracking)
53
+ SessionManager = class {
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
+ }
130
+ };
131
+
132
+ LocalCache = class {
133
+ async getMetadata() { return null; }
134
+ };
135
+
136
+ Logger = class {
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
+ }
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
+ });
159
+ }
17
160
 
18
161
  const logger = new Logger('track-response');
19
162
  const config = new ConfigManager();
20
163
  const cache = new LocalCache();
164
+ const session = new SessionManager();
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
+ }
21
188
 
22
189
  async function main() {
190
+ let response;
191
+ let input;
192
+
23
193
  try {
24
- const input = await readStdin();
25
- const response = JSON.parse(input);
194
+ input = await readStdin();
195
+ response = JSON.parse(input);
26
196
 
27
- const settings = await config.load();
197
+ // Calculate output tokens for ALL responses (Issue #701)
198
+ const outputTokens = estimateOutputTokens(response);
28
199
 
29
- // If Plexor is disabled or no API key, just pass through
30
- if (!settings.enabled || !settings.apiKey) {
31
- 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');
32
211
  }
33
212
 
34
- // Check if this response has Plexor metadata
35
- const plexorMeta = response._plexor;
36
- if (!plexorMeta || !plexorMeta.request_id) {
37
- return output(response);
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}`);
38
222
  }
39
223
 
40
- // Get stored metadata for this request
41
- const metadata = await cache.getMetadata(plexorMeta.request_id);
42
- if (!metadata) {
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) {
43
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);
44
241
  }
242
+ }
243
+ }
45
244
 
46
- // Calculate output tokens (approximate)
47
- const outputTokens = estimateTokens(response.content || '');
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;
48
252
 
49
- // Log response tracking
50
- logger.info('[Plexor] Response tracked', {
51
- request_id: plexorMeta.request_id,
52
- input_tokens: metadata.optimized_tokens,
53
- output_tokens: outputTokens,
54
- provider: metadata.recommended_provider
55
- });
253
+ logger.debug(`Tracking response: source=${source}, request_id=${requestId}`);
56
254
 
57
- // In production, we would send this data to the API for analytics
58
- // For now, just log locally
255
+ switch (source) {
256
+ case 'plexor_api':
257
+ // Full optimization was applied
258
+ session.recordOptimization({
259
+ original_tokens: plexorMeta.original_tokens || 0,
260
+ optimized_tokens: plexorMeta.optimized_tokens || 0,
261
+ tokens_saved: plexorMeta.tokens_saved || 0,
262
+ baseline_cost: plexorMeta.baseline_cost || 0,
263
+ estimated_cost: plexorMeta.estimated_cost || 0,
264
+ output_tokens: outputTokens
265
+ });
266
+ logger.info('[Plexor] Optimized response tracked', {
267
+ request_id: requestId,
268
+ input_tokens: plexorMeta.optimized_tokens,
269
+ output_tokens: outputTokens,
270
+ savings_percent: plexorMeta.savings_percent,
271
+ provider: plexorMeta.recommended_provider
272
+ });
273
+ break;
59
274
 
60
- // Pass through unchanged
61
- return output(response);
275
+ case 'local_cache':
276
+ session.recordCacheHit();
277
+ logger.info('[Plexor] Cache hit recorded', { request_id: requestId });
278
+ break;
62
279
 
63
- } catch (error) {
64
- logger.error(`[Plexor] Tracking error: ${error.message}`);
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;
65
295
 
66
- // On any error, pass through unchanged
67
- try {
68
- const input = await readStdin();
69
- return output(JSON.parse(input));
70
- } catch {
71
- process.exit(1);
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
+ }
305
+
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
+ }
72
325
  }
73
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));
74
345
  }
75
346
 
76
347
  async function readStdin() {
@@ -99,11 +370,6 @@ function output(data) {
99
370
  process.exit(0);
100
371
  }
101
372
 
102
- function estimateTokens(text) {
103
- // Approximate: ~4 characters per token
104
- return Math.max(1, Math.ceil(text.length / 4));
105
- }
106
-
107
373
  main().catch((error) => {
108
374
  console.error(`[Plexor] Fatal error: ${error.message}`);
109
375
  process.exit(1);
package/lib/cache.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Plexor Local Cache
3
+ *
4
+ * Stores request/response metadata for cache hit detection.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const { CACHE_PATH, PLEXOR_DIR } = require('./constants');
9
+
10
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
11
+
12
+ class LocalCache {
13
+ constructor() {
14
+ this.cachePath = CACHE_PATH;
15
+ this.cache = this.load();
16
+ }
17
+
18
+ load() {
19
+ try {
20
+ const data = fs.readFileSync(this.cachePath, 'utf8');
21
+ return JSON.parse(data);
22
+ } catch {
23
+ return { entries: {}, metadata: {} };
24
+ }
25
+ }
26
+
27
+ save() {
28
+ try {
29
+ if (!fs.existsSync(PLEXOR_DIR)) {
30
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true });
31
+ }
32
+ fs.writeFileSync(this.cachePath, JSON.stringify(this.cache, null, 2));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ generateKey(messages) {
40
+ const str = JSON.stringify(messages);
41
+ let hash = 0;
42
+ for (let i = 0; i < str.length; i++) {
43
+ hash = ((hash << 5) - hash) + str.charCodeAt(i);
44
+ hash |= 0;
45
+ }
46
+ return `cache_${Math.abs(hash)}`;
47
+ }
48
+
49
+ async get(key) {
50
+ const entry = this.cache.entries[key];
51
+ if (!entry) {
52
+ return null;
53
+ }
54
+
55
+ // Check if expired
56
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
57
+ delete this.cache.entries[key];
58
+ this.save();
59
+ return null;
60
+ }
61
+
62
+ return entry.value;
63
+ }
64
+
65
+ async set(key, value) {
66
+ this.cache.entries[key] = {
67
+ value,
68
+ timestamp: Date.now()
69
+ };
70
+ this.cleanup();
71
+ this.save();
72
+ }
73
+
74
+ async getMetadata(requestId) {
75
+ return this.cache.metadata[requestId] || null;
76
+ }
77
+
78
+ async setMetadata(requestId, metadata) {
79
+ this.cache.metadata[requestId] = {
80
+ ...metadata,
81
+ timestamp: Date.now()
82
+ };
83
+ this.cleanupMetadata();
84
+ this.save();
85
+ }
86
+
87
+ cleanup() {
88
+ const now = Date.now();
89
+ for (const key of Object.keys(this.cache.entries)) {
90
+ if (now - this.cache.entries[key].timestamp > CACHE_TTL_MS) {
91
+ delete this.cache.entries[key];
92
+ }
93
+ }
94
+ }
95
+
96
+ cleanupMetadata() {
97
+ const now = Date.now();
98
+ const METADATA_TTL_MS = 60 * 60 * 1000; // 1 hour
99
+ for (const key of Object.keys(this.cache.metadata)) {
100
+ if (now - this.cache.metadata[key].timestamp > METADATA_TTL_MS) {
101
+ delete this.cache.metadata[key];
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ module.exports = LocalCache;
package/lib/config.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Plexor Configuration Manager
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { CONFIG_PATH, PLEXOR_DIR, DEFAULT_API_URL, DEFAULT_TIMEOUT } = require('./constants');
8
+
9
+ class ConfigManager {
10
+ constructor() {
11
+ this.configPath = CONFIG_PATH;
12
+ }
13
+
14
+ async load() {
15
+ try {
16
+ const data = fs.readFileSync(this.configPath, 'utf8');
17
+ const cfg = JSON.parse(data);
18
+ return {
19
+ enabled: cfg.settings?.enabled ?? false,
20
+ apiKey: cfg.auth?.api_key || '',
21
+ apiUrl: cfg.settings?.apiUrl || DEFAULT_API_URL,
22
+ timeout: cfg.settings?.timeout || DEFAULT_TIMEOUT,
23
+ localCacheEnabled: cfg.settings?.localCacheEnabled ?? false,
24
+ mode: cfg.settings?.mode || 'balanced',
25
+ preferredProvider: cfg.settings?.preferred_provider || 'auto'
26
+ };
27
+ } catch {
28
+ return { enabled: false, apiKey: '', apiUrl: DEFAULT_API_URL };
29
+ }
30
+ }
31
+
32
+ async save(config) {
33
+ try {
34
+ if (!fs.existsSync(PLEXOR_DIR)) {
35
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true });
36
+ }
37
+
38
+ let existing = {};
39
+ try {
40
+ const data = fs.readFileSync(this.configPath, 'utf8');
41
+ existing = JSON.parse(data);
42
+ } catch {
43
+ existing = { version: 1, auth: {}, settings: {} };
44
+ }
45
+
46
+ const updated = {
47
+ ...existing,
48
+ settings: {
49
+ ...existing.settings,
50
+ enabled: config.enabled ?? existing.settings?.enabled,
51
+ apiUrl: config.apiUrl ?? existing.settings?.apiUrl,
52
+ timeout: config.timeout ?? existing.settings?.timeout,
53
+ localCacheEnabled: config.localCacheEnabled ?? existing.settings?.localCacheEnabled,
54
+ mode: config.mode ?? existing.settings?.mode,
55
+ preferred_provider: config.preferredProvider ?? existing.settings?.preferred_provider
56
+ }
57
+ };
58
+
59
+ fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2));
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ }
66
+
67
+ module.exports = ConfigManager;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Plexor Plugin Constants
3
+ */
4
+
5
+ const path = require('path');
6
+
7
+ const PLEXOR_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.plexor');
8
+ const CONFIG_PATH = path.join(PLEXOR_DIR, 'config.json');
9
+ const SESSION_PATH = path.join(PLEXOR_DIR, 'session.json');
10
+ const CACHE_PATH = path.join(PLEXOR_DIR, 'cache.json');
11
+
12
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
13
+
14
+ const DEFAULT_API_URL = 'https://api.plexor.dev';
15
+ const DEFAULT_TIMEOUT = 5000;
16
+
17
+ module.exports = {
18
+ PLEXOR_DIR,
19
+ CONFIG_PATH,
20
+ SESSION_PATH,
21
+ CACHE_PATH,
22
+ SESSION_TIMEOUT_MS,
23
+ DEFAULT_API_URL,
24
+ DEFAULT_TIMEOUT
25
+ };
package/lib/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Plexor Plugin Library
3
+ */
4
+
5
+ const ConfigManager = require('./config');
6
+ const SessionManager = require('./session');
7
+ const LocalCache = require('./cache');
8
+ const Logger = require('./logger');
9
+ const PlexorClient = require('./plexor-client');
10
+ const constants = require('./constants');
11
+
12
+ module.exports = {
13
+ ConfigManager,
14
+ SessionManager,
15
+ LocalCache,
16
+ Logger,
17
+ PlexorClient,
18
+ ...constants
19
+ };
package/lib/logger.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Plexor Logger
3
+ *
4
+ * Simple logger that outputs to stderr to avoid interfering with stdout JSON.
5
+ */
6
+
7
+ class Logger {
8
+ constructor(component = 'plexor') {
9
+ this.component = component;
10
+ this.debug_enabled = process.env.PLEXOR_DEBUG === '1' || process.env.PLEXOR_DEBUG === 'true';
11
+ }
12
+
13
+ debug(msg, data = null) {
14
+ if (this.debug_enabled) {
15
+ const output = data ? `[DEBUG][${this.component}] ${msg} ${JSON.stringify(data)}` : `[DEBUG][${this.component}] ${msg}`;
16
+ console.error(output);
17
+ }
18
+ }
19
+
20
+ info(msg, data = null) {
21
+ const output = data ? `${msg} ${JSON.stringify(data)}` : msg;
22
+ console.error(output);
23
+ }
24
+
25
+ warn(msg, data = null) {
26
+ const output = data ? `[WARN][${this.component}] ${msg} ${JSON.stringify(data)}` : `[WARN][${this.component}] ${msg}`;
27
+ console.error(output);
28
+ }
29
+
30
+ error(msg, data = null) {
31
+ const output = data ? `[ERROR][${this.component}] ${msg} ${JSON.stringify(data)}` : `[ERROR][${this.component}] ${msg}`;
32
+ console.error(output);
33
+ }
34
+ }
35
+
36
+ module.exports = Logger;