@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,376 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Plexor Response Tracking Hook
5
+ *
6
+ * This script runs after the LLM response is received.
7
+ * It tracks response metrics for analytics and updates session stats.
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
+ *
13
+ * Input: JSON object with response content, tokens used, etc.
14
+ * Output: Passthrough (no modifications)
15
+ */
16
+
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
+ }
160
+
161
+ const logger = new Logger('track-response');
162
+ const config = new ConfigManager();
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
+ }
188
+
189
+ async function main() {
190
+ let response;
191
+ let input;
192
+
193
+ try {
194
+ input = await readStdin();
195
+ response = JSON.parse(input);
196
+
197
+ // Calculate output tokens for ALL responses (Issue #701)
198
+ const outputTokens = estimateOutputTokens(response);
199
+
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');
211
+ }
212
+
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) {
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);
241
+ }
242
+ }
243
+ }
244
+
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;
252
+
253
+ logger.debug(`Tracking response: source=${source}, request_id=${requestId}`);
254
+
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;
274
+
275
+ case 'local_cache':
276
+ session.recordCacheHit();
277
+ logger.info('[Plexor] Cache hit recorded', { request_id: requestId });
278
+ break;
279
+
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;
295
+
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
+ }
325
+ }
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));
345
+ }
346
+
347
+ async function readStdin() {
348
+ return new Promise((resolve, reject) => {
349
+ const chunks = [];
350
+
351
+ process.stdin.on('data', (chunk) => {
352
+ chunks.push(chunk);
353
+ });
354
+
355
+ process.stdin.on('end', () => {
356
+ resolve(Buffer.concat(chunks).toString('utf8'));
357
+ });
358
+
359
+ process.stdin.on('error', reject);
360
+
361
+ setTimeout(() => {
362
+ reject(new Error('Stdin read timeout'));
363
+ }, 2000);
364
+ });
365
+ }
366
+
367
+ function output(data) {
368
+ const json = JSON.stringify(data);
369
+ process.stdout.write(json);
370
+ process.exit(0);
371
+ }
372
+
373
+ main().catch((error) => {
374
+ console.error(`[Plexor] Fatal error: ${error.message}`);
375
+ process.exit(1);
376
+ });
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,26 @@
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
+ // STAGING PACKAGE - uses staging API
15
+ const DEFAULT_API_URL = 'https://staging.api.plexor.dev';
16
+ const DEFAULT_TIMEOUT = 5000;
17
+
18
+ module.exports = {
19
+ PLEXOR_DIR,
20
+ CONFIG_PATH,
21
+ SESSION_PATH,
22
+ CACHE_PATH,
23
+ SESSION_TIMEOUT_MS,
24
+ DEFAULT_API_URL,
25
+ DEFAULT_TIMEOUT
26
+ };
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;