@cognisos/liminal 0.1.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/dist/bin.js +1496 -0
- package/dist/bin.js.map +1 -0
- package/package.json +56 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1496 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/version.ts
|
|
4
|
+
var VERSION = true ? "0.1.0" : "0.1.0";
|
|
5
|
+
var BANNER_LINES = [
|
|
6
|
+
" ___ ___ _____ ______ ___ ________ ________ ___",
|
|
7
|
+
"|\\ \\ |\\ \\|\\ _ \\ _ \\|\\ \\|\\ ___ \\|\\ __ \\|\\ \\",
|
|
8
|
+
"\\ \\ \\ \\ \\ \\ \\ \\\\\\__\\ \\ \\ \\ \\ \\ \\\\ \\ \\ \\ \\|\\ \\ \\ \\",
|
|
9
|
+
" \\ \\ \\ \\ \\ \\ \\ \\\\|__| \\ \\ \\ \\ \\ \\\\ \\ \\ \\ __ \\ \\ \\",
|
|
10
|
+
" \\ \\ \\____\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\\\ \\ \\ \\ \\ \\ \\ \\ \\____",
|
|
11
|
+
" \\ \\_______\\ \\__\\ \\__\\ \\ \\__\\ \\__\\ \\__\\\\ \\__\\ \\__\\ \\__\\ \\_______\\",
|
|
12
|
+
" \\|_______|\\|__|\\|__| \\|__|\\|__|\\|__| \\|__|\\|__|\\|__|\\|_______|"
|
|
13
|
+
];
|
|
14
|
+
function printBanner() {
|
|
15
|
+
console.log();
|
|
16
|
+
for (const line of BANNER_LINES) {
|
|
17
|
+
console.log(line);
|
|
18
|
+
}
|
|
19
|
+
console.log();
|
|
20
|
+
console.log(` v${VERSION} -- brought to you by Cognisos`);
|
|
21
|
+
console.log();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/commands/init.ts
|
|
25
|
+
import { createInterface } from "readline/promises";
|
|
26
|
+
import { stdin, stdout } from "process";
|
|
27
|
+
import { RSCTransport, CircuitBreaker } from "@cognisos/rsc-sdk";
|
|
28
|
+
|
|
29
|
+
// src/config/loader.ts
|
|
30
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
31
|
+
import { dirname } from "path";
|
|
32
|
+
|
|
33
|
+
// src/config/paths.ts
|
|
34
|
+
import { homedir } from "os";
|
|
35
|
+
import { join } from "path";
|
|
36
|
+
var LIMINAL_DIR = join(homedir(), ".liminal");
|
|
37
|
+
var CONFIG_FILE = join(LIMINAL_DIR, "config.json");
|
|
38
|
+
var PID_FILE = join(LIMINAL_DIR, "liminal.pid");
|
|
39
|
+
var LOG_DIR = join(LIMINAL_DIR, "logs");
|
|
40
|
+
var LOG_FILE = join(LOG_DIR, "liminal.log");
|
|
41
|
+
|
|
42
|
+
// src/config/schema.ts
|
|
43
|
+
var DEFAULTS = {
|
|
44
|
+
apiBaseUrl: "https://rsc-platform-production.up.railway.app",
|
|
45
|
+
upstreamBaseUrl: "https://api.openai.com",
|
|
46
|
+
anthropicUpstreamUrl: "https://api.anthropic.com",
|
|
47
|
+
port: 3141,
|
|
48
|
+
compressionThreshold: 100,
|
|
49
|
+
compressRoles: ["user"],
|
|
50
|
+
learnFromResponses: true,
|
|
51
|
+
latencyBudgetMs: 0,
|
|
52
|
+
enabled: true,
|
|
53
|
+
tools: []
|
|
54
|
+
};
|
|
55
|
+
var CONFIGURABLE_KEYS = /* @__PURE__ */ new Set([
|
|
56
|
+
"apiBaseUrl",
|
|
57
|
+
"upstreamBaseUrl",
|
|
58
|
+
"anthropicUpstreamUrl",
|
|
59
|
+
"port",
|
|
60
|
+
"compressionThreshold",
|
|
61
|
+
"compressRoles",
|
|
62
|
+
"learnFromResponses",
|
|
63
|
+
"latencyBudgetMs",
|
|
64
|
+
"enabled",
|
|
65
|
+
"tools"
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// src/config/loader.ts
|
|
69
|
+
function loadConfig() {
|
|
70
|
+
let fileConfig = {};
|
|
71
|
+
if (existsSync(CONFIG_FILE)) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
74
|
+
fileConfig = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const merged = {
|
|
79
|
+
apiKey: fileConfig.apiKey ?? "",
|
|
80
|
+
apiBaseUrl: DEFAULTS.apiBaseUrl,
|
|
81
|
+
upstreamBaseUrl: DEFAULTS.upstreamBaseUrl,
|
|
82
|
+
anthropicUpstreamUrl: DEFAULTS.anthropicUpstreamUrl,
|
|
83
|
+
port: DEFAULTS.port,
|
|
84
|
+
compressionThreshold: DEFAULTS.compressionThreshold,
|
|
85
|
+
compressRoles: DEFAULTS.compressRoles,
|
|
86
|
+
learnFromResponses: DEFAULTS.learnFromResponses,
|
|
87
|
+
latencyBudgetMs: DEFAULTS.latencyBudgetMs,
|
|
88
|
+
enabled: DEFAULTS.enabled,
|
|
89
|
+
tools: DEFAULTS.tools,
|
|
90
|
+
...fileConfig
|
|
91
|
+
};
|
|
92
|
+
if (process.env.LIMINAL_API_KEY) merged.apiKey = process.env.LIMINAL_API_KEY;
|
|
93
|
+
if (process.env.LIMINAL_API_URL) merged.apiBaseUrl = process.env.LIMINAL_API_URL;
|
|
94
|
+
if (process.env.LIMINAL_UPSTREAM_URL) merged.upstreamBaseUrl = process.env.LIMINAL_UPSTREAM_URL;
|
|
95
|
+
if (process.env.LIMINAL_ANTHROPIC_URL) merged.anthropicUpstreamUrl = process.env.LIMINAL_ANTHROPIC_URL;
|
|
96
|
+
if (process.env.LIMINAL_PORT) merged.port = parseInt(process.env.LIMINAL_PORT, 10);
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
function applyOverrides(config, overrides) {
|
|
100
|
+
return { ...config, ...overrides };
|
|
101
|
+
}
|
|
102
|
+
function saveConfig(config) {
|
|
103
|
+
ensureDirectories();
|
|
104
|
+
let existing = {};
|
|
105
|
+
if (existsSync(CONFIG_FILE)) {
|
|
106
|
+
try {
|
|
107
|
+
existing = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
108
|
+
} catch {
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const merged = { ...existing, ...config };
|
|
112
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
113
|
+
}
|
|
114
|
+
function ensureDirectories() {
|
|
115
|
+
if (!existsSync(LIMINAL_DIR)) mkdirSync(LIMINAL_DIR, { recursive: true });
|
|
116
|
+
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
|
117
|
+
const configDir = dirname(CONFIG_FILE);
|
|
118
|
+
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
function isConfigured() {
|
|
121
|
+
try {
|
|
122
|
+
const config = loadConfig();
|
|
123
|
+
return config.apiKey.length > 0;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function maskApiKey(key) {
|
|
129
|
+
if (key.length <= 12) return "****";
|
|
130
|
+
return key.slice(0, 8) + "..." + key.slice(-4);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/commands/init.ts
|
|
134
|
+
var TOOL_MAP = {
|
|
135
|
+
"1": "claude-code",
|
|
136
|
+
"2": "codex",
|
|
137
|
+
"3": "cursor",
|
|
138
|
+
"4": "openai-compatible"
|
|
139
|
+
};
|
|
140
|
+
function parseToolSelection(input) {
|
|
141
|
+
const nums = input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
142
|
+
const tools = nums.map((n) => TOOL_MAP[n]).filter((t) => !!t);
|
|
143
|
+
return tools.length > 0 ? tools : ["claude-code"];
|
|
144
|
+
}
|
|
145
|
+
function printToolInstructions(tool, port) {
|
|
146
|
+
const base = `http://127.0.0.1:${port}`;
|
|
147
|
+
switch (tool) {
|
|
148
|
+
case "claude-code":
|
|
149
|
+
console.log(" Claude Code:");
|
|
150
|
+
console.log(` export ANTHROPIC_BASE_URL=${base}`);
|
|
151
|
+
console.log(" (Add to your shell profile for persistence)");
|
|
152
|
+
console.log();
|
|
153
|
+
break;
|
|
154
|
+
case "codex":
|
|
155
|
+
console.log(" Codex:");
|
|
156
|
+
console.log(` export OPENAI_BASE_URL=${base}/v1`);
|
|
157
|
+
console.log(" (Add to your shell profile for persistence)");
|
|
158
|
+
console.log();
|
|
159
|
+
break;
|
|
160
|
+
case "cursor":
|
|
161
|
+
console.log(" Cursor:");
|
|
162
|
+
console.log(` Settings > Models > OpenAI API Base URL: ${base}/v1`);
|
|
163
|
+
console.log();
|
|
164
|
+
break;
|
|
165
|
+
case "openai-compatible":
|
|
166
|
+
console.log(" OpenAI-compatible tools:");
|
|
167
|
+
console.log(` Set your base URL to: ${base}/v1`);
|
|
168
|
+
console.log();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function initCommand() {
|
|
173
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
174
|
+
printBanner();
|
|
175
|
+
console.log(" Welcome to Liminal -- Your Transparency & Context Partner");
|
|
176
|
+
console.log();
|
|
177
|
+
console.log(" Let's start evolving.");
|
|
178
|
+
console.log();
|
|
179
|
+
try {
|
|
180
|
+
const apiKey = await rl.question(" \x1B[1mLiminal API key\x1B[0m: ");
|
|
181
|
+
if (!apiKey.trim()) {
|
|
182
|
+
console.error("\n Error: API key is required.");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const apiBaseUrlInput = await rl.question(` Liminal API URL [${DEFAULTS.apiBaseUrl}]: `);
|
|
186
|
+
const apiBaseUrl = apiBaseUrlInput.trim() || DEFAULTS.apiBaseUrl;
|
|
187
|
+
const portInput = await rl.question(` Proxy port [${DEFAULTS.port}]: `);
|
|
188
|
+
const port = portInput.trim() ? parseInt(portInput.trim(), 10) : DEFAULTS.port;
|
|
189
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
190
|
+
console.error("\n Error: Invalid port number.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
console.log();
|
|
194
|
+
console.log(" Which AI tools will you use with Liminal?");
|
|
195
|
+
console.log();
|
|
196
|
+
console.log(" 1) Claude Code");
|
|
197
|
+
console.log(" 2) Codex");
|
|
198
|
+
console.log(" 3) Cursor");
|
|
199
|
+
console.log(" 4) Other / OpenAI-compatible");
|
|
200
|
+
console.log();
|
|
201
|
+
const toolsInput = await rl.question(" Select tools (comma-separated, e.g. 1,3) [1]: ");
|
|
202
|
+
const tools = parseToolSelection(toolsInput.trim() || "1");
|
|
203
|
+
const learnInput = await rl.question(" Learn from LLM responses? [Y/n]: ");
|
|
204
|
+
const learnFromResponses = learnInput.trim().toLowerCase() !== "n";
|
|
205
|
+
console.log();
|
|
206
|
+
process.stdout.write(" Validating API key... ");
|
|
207
|
+
try {
|
|
208
|
+
const breaker = new CircuitBreaker(3, 1e4);
|
|
209
|
+
const transport = new RSCTransport({
|
|
210
|
+
baseUrl: apiBaseUrl,
|
|
211
|
+
apiKey: apiKey.trim(),
|
|
212
|
+
timeout: 1e4,
|
|
213
|
+
maxRetries: 1,
|
|
214
|
+
circuitBreaker: breaker
|
|
215
|
+
});
|
|
216
|
+
await transport.get("/health");
|
|
217
|
+
console.log("OK");
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
console.log("FAILED");
|
|
221
|
+
console.error(`
|
|
222
|
+
Could not connect to Liminal API: ${message}`);
|
|
223
|
+
console.error(" Check your API key and URL, then try again.");
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
ensureDirectories();
|
|
227
|
+
saveConfig({
|
|
228
|
+
apiKey: apiKey.trim(),
|
|
229
|
+
apiBaseUrl,
|
|
230
|
+
upstreamBaseUrl: DEFAULTS.upstreamBaseUrl,
|
|
231
|
+
anthropicUpstreamUrl: DEFAULTS.anthropicUpstreamUrl,
|
|
232
|
+
port,
|
|
233
|
+
learnFromResponses,
|
|
234
|
+
tools,
|
|
235
|
+
compressionThreshold: DEFAULTS.compressionThreshold,
|
|
236
|
+
compressRoles: DEFAULTS.compressRoles,
|
|
237
|
+
latencyBudgetMs: DEFAULTS.latencyBudgetMs,
|
|
238
|
+
enabled: DEFAULTS.enabled
|
|
239
|
+
});
|
|
240
|
+
console.log();
|
|
241
|
+
console.log(` Configuration saved to ${CONFIG_FILE}`);
|
|
242
|
+
console.log();
|
|
243
|
+
console.log(" Next steps:");
|
|
244
|
+
console.log(" 1. Start the proxy: liminal start");
|
|
245
|
+
console.log(" 2. Connect your tools:");
|
|
246
|
+
console.log();
|
|
247
|
+
for (const tool of tools) {
|
|
248
|
+
printToolInstructions(tool, port);
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
rl.close();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/rsc/pipeline.ts
|
|
256
|
+
import {
|
|
257
|
+
CompressionPipeline,
|
|
258
|
+
RSCTransport as RSCTransport2,
|
|
259
|
+
RSCEventEmitter,
|
|
260
|
+
Session,
|
|
261
|
+
CircuitBreaker as CircuitBreaker2
|
|
262
|
+
} from "@cognisos/rsc-sdk";
|
|
263
|
+
var RSCPipelineWrapper = class {
|
|
264
|
+
pipeline;
|
|
265
|
+
session;
|
|
266
|
+
events;
|
|
267
|
+
transport;
|
|
268
|
+
circuitBreaker;
|
|
269
|
+
constructor(config) {
|
|
270
|
+
this.circuitBreaker = new CircuitBreaker2(5, 5 * 60 * 1e3);
|
|
271
|
+
this.transport = new RSCTransport2({
|
|
272
|
+
baseUrl: config.rscBaseUrl,
|
|
273
|
+
apiKey: config.rscApiKey,
|
|
274
|
+
timeout: 3e4,
|
|
275
|
+
maxRetries: 3,
|
|
276
|
+
circuitBreaker: this.circuitBreaker
|
|
277
|
+
});
|
|
278
|
+
this.events = new RSCEventEmitter();
|
|
279
|
+
this.session = new Session(config.sessionId);
|
|
280
|
+
this.pipeline = new CompressionPipeline(
|
|
281
|
+
this.transport,
|
|
282
|
+
{
|
|
283
|
+
threshold: config.compressionThreshold,
|
|
284
|
+
learnFromResponses: config.learnFromResponses,
|
|
285
|
+
latencyBudgetMs: config.latencyBudgetMs,
|
|
286
|
+
sessionId: this.session.sessionId
|
|
287
|
+
},
|
|
288
|
+
this.events
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
async healthCheck() {
|
|
292
|
+
try {
|
|
293
|
+
await this.transport.get("/health");
|
|
294
|
+
return true;
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
getSessionSummary() {
|
|
300
|
+
return this.session.getSummary();
|
|
301
|
+
}
|
|
302
|
+
getCircuitState() {
|
|
303
|
+
return this.circuitBreaker.getState();
|
|
304
|
+
}
|
|
305
|
+
isCircuitOpen() {
|
|
306
|
+
return this.circuitBreaker.getState() === "open";
|
|
307
|
+
}
|
|
308
|
+
resetCircuitBreaker() {
|
|
309
|
+
this.circuitBreaker.reset();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// src/proxy/completions.ts
|
|
314
|
+
import { RSCCircuitOpenError as RSCCircuitOpenError2 } from "@cognisos/rsc-sdk";
|
|
315
|
+
|
|
316
|
+
// src/rsc/message-compressor.ts
|
|
317
|
+
import { RSCCircuitOpenError } from "@cognisos/rsc-sdk";
|
|
318
|
+
async function compressMessages(messages, pipeline, session, compressRoles) {
|
|
319
|
+
let anyCompressed = false;
|
|
320
|
+
let totalTokensSaved = 0;
|
|
321
|
+
const compressed = await Promise.all(
|
|
322
|
+
messages.map(async (msg) => {
|
|
323
|
+
if (!compressRoles.has(msg.role)) return msg;
|
|
324
|
+
if (typeof msg.content === "string") {
|
|
325
|
+
return compressStringContent(msg, pipeline, session, (c, saved) => {
|
|
326
|
+
anyCompressed = anyCompressed || c;
|
|
327
|
+
totalTokensSaved += saved;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (Array.isArray(msg.content)) {
|
|
331
|
+
return compressArrayContent(msg, pipeline, session, (c, saved) => {
|
|
332
|
+
anyCompressed = anyCompressed || c;
|
|
333
|
+
totalTokensSaved += saved;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return msg;
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
return { messages: compressed, anyCompressed, totalTokensSaved };
|
|
340
|
+
}
|
|
341
|
+
async function compressStringContent(msg, pipeline, session, record) {
|
|
342
|
+
try {
|
|
343
|
+
const result = await pipeline.compressForLLM(msg.content);
|
|
344
|
+
session.recordCompression(result.metrics);
|
|
345
|
+
record(!result.metrics.skipped, result.metrics.tokensSaved);
|
|
346
|
+
return { ...msg, content: result.text };
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if (err instanceof RSCCircuitOpenError) {
|
|
349
|
+
session.recordFailure();
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
session.recordFailure();
|
|
353
|
+
return msg;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function compressArrayContent(msg, pipeline, session, record) {
|
|
357
|
+
const parts = msg.content;
|
|
358
|
+
const compressedParts = await Promise.all(
|
|
359
|
+
parts.map(async (part) => {
|
|
360
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
361
|
+
try {
|
|
362
|
+
const result = await pipeline.compressForLLM(part.text);
|
|
363
|
+
session.recordCompression(result.metrics);
|
|
364
|
+
record(!result.metrics.skipped, result.metrics.tokensSaved);
|
|
365
|
+
return { ...part, text: result.text };
|
|
366
|
+
} catch (err) {
|
|
367
|
+
if (err instanceof RSCCircuitOpenError) {
|
|
368
|
+
session.recordFailure();
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
session.recordFailure();
|
|
372
|
+
return part;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return part;
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
return { ...msg, content: compressedParts };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/rsc/learning.ts
|
|
382
|
+
function createStreamLearningBuffer(pipeline) {
|
|
383
|
+
let buffer = "";
|
|
384
|
+
return {
|
|
385
|
+
/** Append a text delta from an SSE chunk */
|
|
386
|
+
append(text) {
|
|
387
|
+
buffer += text;
|
|
388
|
+
},
|
|
389
|
+
/** Flush the buffer — triggers fire-and-forget learning */
|
|
390
|
+
flush() {
|
|
391
|
+
if (buffer.length > 0) {
|
|
392
|
+
pipeline.triggerLearning(buffer);
|
|
393
|
+
buffer = "";
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
/** Get current buffer contents (for testing) */
|
|
397
|
+
getBuffer() {
|
|
398
|
+
return buffer;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/proxy/streaming.ts
|
|
404
|
+
async function pipeSSEResponse(upstreamResponse, clientRes, onContentDelta, onComplete) {
|
|
405
|
+
clientRes.writeHead(200, {
|
|
406
|
+
"Content-Type": "text/event-stream",
|
|
407
|
+
"Cache-Control": "no-cache",
|
|
408
|
+
"Connection": "keep-alive",
|
|
409
|
+
"Access-Control-Allow-Origin": "*"
|
|
410
|
+
});
|
|
411
|
+
const reader = upstreamResponse.body.getReader();
|
|
412
|
+
const decoder = new TextDecoder();
|
|
413
|
+
let lineBuf = "";
|
|
414
|
+
try {
|
|
415
|
+
while (true) {
|
|
416
|
+
const { done, value } = await reader.read();
|
|
417
|
+
if (done) break;
|
|
418
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
419
|
+
clientRes.write(chunk);
|
|
420
|
+
lineBuf += chunk;
|
|
421
|
+
const lines = lineBuf.split("\n");
|
|
422
|
+
lineBuf = lines.pop() || "";
|
|
423
|
+
for (const line of lines) {
|
|
424
|
+
if (line.startsWith("data: ") && line !== "data: [DONE]") {
|
|
425
|
+
try {
|
|
426
|
+
const json = JSON.parse(line.slice(6));
|
|
427
|
+
const content = json?.choices?.[0]?.delta?.content;
|
|
428
|
+
if (typeof content === "string") {
|
|
429
|
+
onContentDelta(content);
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
clientRes.end();
|
|
438
|
+
onComplete();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/proxy/completions.ts
|
|
443
|
+
function setCORSHeaders(res) {
|
|
444
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
445
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
446
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
447
|
+
}
|
|
448
|
+
function sendJSON(res, status, body) {
|
|
449
|
+
setCORSHeaders(res);
|
|
450
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
451
|
+
res.end(JSON.stringify(body));
|
|
452
|
+
}
|
|
453
|
+
function extractBearerToken(req) {
|
|
454
|
+
const auth = req.headers.authorization;
|
|
455
|
+
if (!auth || !auth.startsWith("Bearer ")) return null;
|
|
456
|
+
return auth.slice(7);
|
|
457
|
+
}
|
|
458
|
+
async function handleChatCompletions(req, res, body, pipeline, config, logger) {
|
|
459
|
+
const request = body;
|
|
460
|
+
if (!request.messages || !Array.isArray(request.messages)) {
|
|
461
|
+
sendJSON(res, 400, {
|
|
462
|
+
error: { message: "messages is required and must be an array", type: "invalid_request_error" }
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const llmApiKey = extractBearerToken(req);
|
|
467
|
+
if (!llmApiKey) {
|
|
468
|
+
sendJSON(res, 401, {
|
|
469
|
+
error: { message: "Authorization header with Bearer token is required", type: "authentication_error" }
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let messages = request.messages;
|
|
474
|
+
let anyCompressed = false;
|
|
475
|
+
if (config.enabled && !pipeline.isCircuitOpen()) {
|
|
476
|
+
try {
|
|
477
|
+
const compressRoles = new Set(config.compressRoles);
|
|
478
|
+
const result = await compressMessages(
|
|
479
|
+
request.messages,
|
|
480
|
+
pipeline.pipeline,
|
|
481
|
+
pipeline.session,
|
|
482
|
+
compressRoles
|
|
483
|
+
);
|
|
484
|
+
messages = result.messages;
|
|
485
|
+
anyCompressed = result.anyCompressed;
|
|
486
|
+
if (result.totalTokensSaved > 0) {
|
|
487
|
+
logger.log(`[COMPRESS] Saved ${result.totalTokensSaved} tokens`);
|
|
488
|
+
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (err instanceof RSCCircuitOpenError2) {
|
|
491
|
+
logger.log("[DEGRADE] Circuit breaker open \u2014 passing through directly");
|
|
492
|
+
} else {
|
|
493
|
+
logger.log(`[ERROR] Compression failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
494
|
+
}
|
|
495
|
+
messages = request.messages;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const upstreamUrl = `${config.upstreamBaseUrl}/v1/chat/completions`;
|
|
499
|
+
const upstreamBody = { ...request, messages };
|
|
500
|
+
const upstreamHeaders = {
|
|
501
|
+
"Authorization": `Bearer ${llmApiKey}`,
|
|
502
|
+
"Content-Type": "application/json"
|
|
503
|
+
};
|
|
504
|
+
if (request.stream) {
|
|
505
|
+
upstreamHeaders["Accept"] = "text/event-stream";
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: upstreamHeaders,
|
|
511
|
+
body: JSON.stringify(upstreamBody)
|
|
512
|
+
});
|
|
513
|
+
if (!upstreamResponse.ok) {
|
|
514
|
+
const errorBody = await upstreamResponse.text();
|
|
515
|
+
setCORSHeaders(res);
|
|
516
|
+
res.writeHead(upstreamResponse.status, {
|
|
517
|
+
"Content-Type": upstreamResponse.headers.get("Content-Type") || "application/json"
|
|
518
|
+
});
|
|
519
|
+
res.end(errorBody);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (request.stream && upstreamResponse.body) {
|
|
523
|
+
const learningBuffer = anyCompressed ? createStreamLearningBuffer(pipeline.pipeline) : null;
|
|
524
|
+
await pipeSSEResponse(
|
|
525
|
+
upstreamResponse,
|
|
526
|
+
res,
|
|
527
|
+
(text) => learningBuffer?.append(text),
|
|
528
|
+
() => learningBuffer?.flush()
|
|
529
|
+
);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const responseBody = await upstreamResponse.text();
|
|
533
|
+
setCORSHeaders(res);
|
|
534
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
535
|
+
res.end(responseBody);
|
|
536
|
+
if (anyCompressed) {
|
|
537
|
+
try {
|
|
538
|
+
const parsed = JSON.parse(responseBody);
|
|
539
|
+
const content = parsed?.choices?.[0]?.message?.content;
|
|
540
|
+
if (typeof content === "string" && content.length > 0) {
|
|
541
|
+
pipeline.pipeline.triggerLearning(content);
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
548
|
+
logger.log(`[ERROR] Upstream request failed: ${message}`);
|
|
549
|
+
if (!res.headersSent) {
|
|
550
|
+
sendJSON(res, 502, {
|
|
551
|
+
error: { message: `Failed to reach upstream LLM: ${message}`, type: "server_error" }
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/proxy/messages.ts
|
|
558
|
+
import { RSCCircuitOpenError as RSCCircuitOpenError3 } from "@cognisos/rsc-sdk";
|
|
559
|
+
|
|
560
|
+
// src/proxy/anthropic-streaming.ts
|
|
561
|
+
async function pipeAnthropicSSEResponse(upstreamResponse, clientRes, onContentDelta, onComplete) {
|
|
562
|
+
clientRes.writeHead(200, {
|
|
563
|
+
"Content-Type": "text/event-stream",
|
|
564
|
+
"Cache-Control": "no-cache",
|
|
565
|
+
"Connection": "keep-alive",
|
|
566
|
+
"Access-Control-Allow-Origin": "*"
|
|
567
|
+
});
|
|
568
|
+
const reader = upstreamResponse.body.getReader();
|
|
569
|
+
const decoder = new TextDecoder();
|
|
570
|
+
let lineBuf = "";
|
|
571
|
+
let currentEvent = "";
|
|
572
|
+
try {
|
|
573
|
+
while (true) {
|
|
574
|
+
const { done, value } = await reader.read();
|
|
575
|
+
if (done) break;
|
|
576
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
577
|
+
clientRes.write(chunk);
|
|
578
|
+
lineBuf += chunk;
|
|
579
|
+
const lines = lineBuf.split("\n");
|
|
580
|
+
lineBuf = lines.pop() || "";
|
|
581
|
+
for (const line of lines) {
|
|
582
|
+
if (line.startsWith("event: ")) {
|
|
583
|
+
currentEvent = line.slice(7).trim();
|
|
584
|
+
} else if (line.startsWith("data: ") && currentEvent === "content_block_delta") {
|
|
585
|
+
try {
|
|
586
|
+
const json = JSON.parse(line.slice(6));
|
|
587
|
+
if (json?.delta?.type === "text_delta" && typeof json.delta.text === "string") {
|
|
588
|
+
onContentDelta(json.delta.text);
|
|
589
|
+
}
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} finally {
|
|
596
|
+
clientRes.end();
|
|
597
|
+
onComplete();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/proxy/messages.ts
|
|
602
|
+
function setCORSHeaders2(res) {
|
|
603
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
604
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
605
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta");
|
|
606
|
+
}
|
|
607
|
+
function sendAnthropicError(res, status, type, message) {
|
|
608
|
+
setCORSHeaders2(res);
|
|
609
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
610
|
+
res.end(JSON.stringify({ type: "error", error: { type, message } }));
|
|
611
|
+
}
|
|
612
|
+
function extractAnthropicApiKey(req) {
|
|
613
|
+
const key = req.headers["x-api-key"];
|
|
614
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
function convertAnthropicToCompressible(messages) {
|
|
618
|
+
return messages.map((msg) => ({
|
|
619
|
+
role: msg.role,
|
|
620
|
+
content: msg.content
|
|
621
|
+
}));
|
|
622
|
+
}
|
|
623
|
+
function convertCompressedToAnthropic(messages) {
|
|
624
|
+
return messages.map((msg) => ({
|
|
625
|
+
role: msg.role,
|
|
626
|
+
content: msg.content
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
async function handleAnthropicMessages(req, res, body, pipeline, config, logger) {
|
|
630
|
+
const request = body;
|
|
631
|
+
if (!request.messages || !Array.isArray(request.messages)) {
|
|
632
|
+
sendAnthropicError(res, 400, "invalid_request_error", "messages is required and must be an array");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (typeof request.max_tokens !== "number") {
|
|
636
|
+
sendAnthropicError(res, 400, "invalid_request_error", "max_tokens is required");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const apiKey = extractAnthropicApiKey(req);
|
|
640
|
+
if (!apiKey) {
|
|
641
|
+
sendAnthropicError(res, 401, "authentication_error", "x-api-key header is required");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
let messages = request.messages;
|
|
645
|
+
let anyCompressed = false;
|
|
646
|
+
if (config.enabled && !pipeline.isCircuitOpen()) {
|
|
647
|
+
try {
|
|
648
|
+
const compressRoles = new Set(config.compressRoles);
|
|
649
|
+
const compressible = convertAnthropicToCompressible(request.messages);
|
|
650
|
+
const result = await compressMessages(
|
|
651
|
+
compressible,
|
|
652
|
+
pipeline.pipeline,
|
|
653
|
+
pipeline.session,
|
|
654
|
+
compressRoles
|
|
655
|
+
);
|
|
656
|
+
messages = convertCompressedToAnthropic(result.messages);
|
|
657
|
+
anyCompressed = result.anyCompressed;
|
|
658
|
+
if (result.totalTokensSaved > 0) {
|
|
659
|
+
logger.log(`[COMPRESS] Saved ${result.totalTokensSaved} tokens`);
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
if (err instanceof RSCCircuitOpenError3) {
|
|
663
|
+
logger.log("[DEGRADE] Circuit breaker open -- passing through directly");
|
|
664
|
+
} else {
|
|
665
|
+
logger.log(`[ERROR] Compression failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
666
|
+
}
|
|
667
|
+
messages = request.messages;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const upstreamUrl = `${config.anthropicUpstreamUrl}/v1/messages`;
|
|
671
|
+
const upstreamBody = { ...request, messages };
|
|
672
|
+
const upstreamHeaders = {
|
|
673
|
+
"x-api-key": apiKey,
|
|
674
|
+
"anthropic-version": req.headers["anthropic-version"] || "2023-06-01",
|
|
675
|
+
"Content-Type": "application/json"
|
|
676
|
+
};
|
|
677
|
+
const betaHeader = req.headers["anthropic-beta"];
|
|
678
|
+
if (typeof betaHeader === "string") {
|
|
679
|
+
upstreamHeaders["anthropic-beta"] = betaHeader;
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: upstreamHeaders,
|
|
685
|
+
body: JSON.stringify(upstreamBody)
|
|
686
|
+
});
|
|
687
|
+
if (!upstreamResponse.ok) {
|
|
688
|
+
const errorBody = await upstreamResponse.text();
|
|
689
|
+
setCORSHeaders2(res);
|
|
690
|
+
res.writeHead(upstreamResponse.status, {
|
|
691
|
+
"Content-Type": upstreamResponse.headers.get("Content-Type") || "application/json"
|
|
692
|
+
});
|
|
693
|
+
res.end(errorBody);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (request.stream && upstreamResponse.body) {
|
|
697
|
+
const learningBuffer = anyCompressed ? createStreamLearningBuffer(pipeline.pipeline) : null;
|
|
698
|
+
await pipeAnthropicSSEResponse(
|
|
699
|
+
upstreamResponse,
|
|
700
|
+
res,
|
|
701
|
+
(text) => learningBuffer?.append(text),
|
|
702
|
+
() => learningBuffer?.flush()
|
|
703
|
+
);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const responseBody = await upstreamResponse.text();
|
|
707
|
+
setCORSHeaders2(res);
|
|
708
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
709
|
+
res.end(responseBody);
|
|
710
|
+
if (anyCompressed) {
|
|
711
|
+
try {
|
|
712
|
+
const parsed = JSON.parse(responseBody);
|
|
713
|
+
const textBlocks = parsed?.content?.filter(
|
|
714
|
+
(b) => b.type === "text" && typeof b.text === "string"
|
|
715
|
+
);
|
|
716
|
+
const content = textBlocks?.map((b) => b.text).join("");
|
|
717
|
+
if (typeof content === "string" && content.length > 0) {
|
|
718
|
+
pipeline.pipeline.triggerLearning(content);
|
|
719
|
+
}
|
|
720
|
+
} catch {
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
725
|
+
logger.log(`[ERROR] Upstream request failed: ${message}`);
|
|
726
|
+
if (!res.headersSent) {
|
|
727
|
+
sendAnthropicError(res, 502, "api_error", `Failed to reach upstream: ${message}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/proxy/handler.ts
|
|
733
|
+
function setCORSHeaders3(res) {
|
|
734
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
735
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
736
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta");
|
|
737
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
738
|
+
}
|
|
739
|
+
function sendJSON2(res, status, body) {
|
|
740
|
+
setCORSHeaders3(res);
|
|
741
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
742
|
+
res.end(JSON.stringify(body));
|
|
743
|
+
}
|
|
744
|
+
function readBody(req) {
|
|
745
|
+
return new Promise((resolve, reject) => {
|
|
746
|
+
const chunks = [];
|
|
747
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
748
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
749
|
+
req.on("error", reject);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function createRequestHandler(pipeline, config, logger) {
|
|
753
|
+
const startTime = Date.now();
|
|
754
|
+
return async (req, res) => {
|
|
755
|
+
try {
|
|
756
|
+
const method = req.method?.toUpperCase() ?? "";
|
|
757
|
+
const url = req.url ?? "";
|
|
758
|
+
if (method === "OPTIONS") {
|
|
759
|
+
setCORSHeaders3(res);
|
|
760
|
+
res.writeHead(204);
|
|
761
|
+
res.end();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (method === "GET" && (url === "/health" || url === "/")) {
|
|
765
|
+
const summary = pipeline.getSessionSummary();
|
|
766
|
+
sendJSON2(res, 200, {
|
|
767
|
+
status: "ok",
|
|
768
|
+
version: config.rscApiKey ? "connected" : "no-api-key",
|
|
769
|
+
rsc_connected: !pipeline.isCircuitOpen(),
|
|
770
|
+
circuit_state: pipeline.getCircuitState(),
|
|
771
|
+
session_id: summary.sessionId,
|
|
772
|
+
uptime_ms: Date.now() - startTime,
|
|
773
|
+
session: {
|
|
774
|
+
tokens_processed: summary.tokensProcessed,
|
|
775
|
+
tokens_saved: summary.tokensSaved,
|
|
776
|
+
calls_total: summary.totalCalls,
|
|
777
|
+
calls_compressed: summary.compressedCalls,
|
|
778
|
+
calls_skipped: summary.skippedCalls,
|
|
779
|
+
calls_failed: summary.failedCalls,
|
|
780
|
+
patterns_learned: summary.patternsLearned,
|
|
781
|
+
estimated_cost_saved_usd: summary.estimatedCostSaved
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (method === "GET" && (url === "/v1/models" || url === "/models")) {
|
|
787
|
+
const llmApiKey = req.headers.authorization?.slice(7);
|
|
788
|
+
if (!llmApiKey) {
|
|
789
|
+
sendJSON2(res, 401, {
|
|
790
|
+
error: { message: "Authorization header with Bearer token is required", type: "authentication_error" }
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
const upstreamRes = await fetch(`${config.upstreamBaseUrl}/v1/models`, {
|
|
796
|
+
headers: { "Authorization": `Bearer ${llmApiKey}` }
|
|
797
|
+
});
|
|
798
|
+
const body = await upstreamRes.text();
|
|
799
|
+
setCORSHeaders3(res);
|
|
800
|
+
res.writeHead(upstreamRes.status, {
|
|
801
|
+
"Content-Type": upstreamRes.headers.get("Content-Type") || "application/json"
|
|
802
|
+
});
|
|
803
|
+
res.end(body);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
806
|
+
sendJSON2(res, 502, {
|
|
807
|
+
error: { message: `Failed to reach upstream: ${message}`, type: "server_error" }
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (method === "POST" && (url === "/v1/chat/completions" || url === "/chat/completions")) {
|
|
813
|
+
const body = await readBody(req);
|
|
814
|
+
let parsed;
|
|
815
|
+
try {
|
|
816
|
+
parsed = JSON.parse(body);
|
|
817
|
+
} catch {
|
|
818
|
+
sendJSON2(res, 400, {
|
|
819
|
+
error: { message: "Invalid JSON body", type: "invalid_request_error" }
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
await handleChatCompletions(req, res, parsed, pipeline, config, logger);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (method === "POST" && (url === "/v1/messages" || url === "/messages")) {
|
|
827
|
+
const body = await readBody(req);
|
|
828
|
+
let parsed;
|
|
829
|
+
try {
|
|
830
|
+
parsed = JSON.parse(body);
|
|
831
|
+
} catch {
|
|
832
|
+
sendJSON2(res, 400, {
|
|
833
|
+
type: "error",
|
|
834
|
+
error: { type: "invalid_request_error", message: "Invalid JSON body" }
|
|
835
|
+
});
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
await handleAnthropicMessages(req, res, parsed, pipeline, config, logger);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
sendJSON2(res, 404, {
|
|
842
|
+
error: { message: `Not found: ${method} ${url}`, type: "invalid_request_error" }
|
|
843
|
+
});
|
|
844
|
+
} catch (err) {
|
|
845
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
846
|
+
logger.log(`[ERROR] Proxy handler error: ${message}`);
|
|
847
|
+
if (!res.headersSent) {
|
|
848
|
+
sendJSON2(res, 500, {
|
|
849
|
+
error: { message: "Internal proxy error", type: "server_error" }
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/proxy/server.ts
|
|
857
|
+
import * as http from "http";
|
|
858
|
+
var MAX_PORT_RETRIES = 5;
|
|
859
|
+
var ProxyServer = class {
|
|
860
|
+
server = null;
|
|
861
|
+
activePort = null;
|
|
862
|
+
requestedPort;
|
|
863
|
+
handler;
|
|
864
|
+
constructor(port, handler) {
|
|
865
|
+
this.requestedPort = port;
|
|
866
|
+
this.handler = handler;
|
|
867
|
+
}
|
|
868
|
+
async start() {
|
|
869
|
+
let lastError = null;
|
|
870
|
+
for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
|
|
871
|
+
const port = this.requestedPort + attempt;
|
|
872
|
+
try {
|
|
873
|
+
await this.listen(port);
|
|
874
|
+
this.activePort = port;
|
|
875
|
+
return port;
|
|
876
|
+
} catch (err) {
|
|
877
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
878
|
+
if (err.code !== "EADDRINUSE") {
|
|
879
|
+
throw lastError;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
throw lastError ?? new Error(`All ports ${this.requestedPort}-${this.requestedPort + MAX_PORT_RETRIES - 1} in use`);
|
|
884
|
+
}
|
|
885
|
+
listen(port) {
|
|
886
|
+
return new Promise((resolve, reject) => {
|
|
887
|
+
const server = http.createServer(this.handler);
|
|
888
|
+
server.on("error", reject);
|
|
889
|
+
server.listen(port, "127.0.0.1", () => {
|
|
890
|
+
server.removeListener("error", reject);
|
|
891
|
+
this.server = server;
|
|
892
|
+
resolve();
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
async stop() {
|
|
897
|
+
if (!this.server) return;
|
|
898
|
+
return new Promise((resolve) => {
|
|
899
|
+
this.server.close(() => {
|
|
900
|
+
this.server = null;
|
|
901
|
+
this.activePort = null;
|
|
902
|
+
resolve();
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
isRunning() {
|
|
907
|
+
return this.server !== null && this.server.listening;
|
|
908
|
+
}
|
|
909
|
+
getPort() {
|
|
910
|
+
return this.activePort;
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// src/daemon/logger.ts
|
|
915
|
+
import { appendFileSync, statSync, renameSync, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
916
|
+
import { dirname as dirname2 } from "path";
|
|
917
|
+
var MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
918
|
+
var MAX_BACKUPS = 2;
|
|
919
|
+
var FileLogger = class {
|
|
920
|
+
logFile;
|
|
921
|
+
mirrorStdout;
|
|
922
|
+
constructor(options) {
|
|
923
|
+
this.logFile = options?.logFile ?? LOG_FILE;
|
|
924
|
+
this.mirrorStdout = options?.mirrorStdout ?? false;
|
|
925
|
+
const logDir = dirname2(this.logFile);
|
|
926
|
+
if (!existsSync2(logDir)) {
|
|
927
|
+
mkdirSync2(logDir, { recursive: true });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
log(message) {
|
|
931
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
932
|
+
const line = `[${timestamp}] ${message}
|
|
933
|
+
`;
|
|
934
|
+
try {
|
|
935
|
+
appendFileSync(this.logFile, line);
|
|
936
|
+
} catch {
|
|
937
|
+
process.stderr.write(`[LOG-WRITE-FAILED] ${line}`);
|
|
938
|
+
}
|
|
939
|
+
if (this.mirrorStdout) {
|
|
940
|
+
process.stdout.write(line);
|
|
941
|
+
}
|
|
942
|
+
this.rotateIfNeeded();
|
|
943
|
+
}
|
|
944
|
+
rotateIfNeeded() {
|
|
945
|
+
try {
|
|
946
|
+
const stats = statSync(this.logFile);
|
|
947
|
+
if (stats.size <= MAX_LOG_SIZE) return;
|
|
948
|
+
for (let i = MAX_BACKUPS - 1; i >= 1; i--) {
|
|
949
|
+
const from = `${this.logFile}.${i}`;
|
|
950
|
+
const to = `${this.logFile}.${i + 1}`;
|
|
951
|
+
if (existsSync2(from)) renameSync(from, to);
|
|
952
|
+
}
|
|
953
|
+
renameSync(this.logFile, `${this.logFile}.1`);
|
|
954
|
+
} catch {
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
getLogFile() {
|
|
958
|
+
return this.logFile;
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// src/daemon/lifecycle.ts
|
|
963
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, existsSync as existsSync3 } from "fs";
|
|
964
|
+
import { fork } from "child_process";
|
|
965
|
+
import { fileURLToPath } from "url";
|
|
966
|
+
function writePidFile(pid) {
|
|
967
|
+
writeFileSync2(PID_FILE, String(pid), "utf-8");
|
|
968
|
+
}
|
|
969
|
+
function readPidFile() {
|
|
970
|
+
if (!existsSync3(PID_FILE)) return null;
|
|
971
|
+
try {
|
|
972
|
+
const content = readFileSync2(PID_FILE, "utf-8").trim();
|
|
973
|
+
const pid = parseInt(content, 10);
|
|
974
|
+
return isNaN(pid) ? null : pid;
|
|
975
|
+
} catch {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function removePidFile() {
|
|
980
|
+
try {
|
|
981
|
+
if (existsSync3(PID_FILE)) unlinkSync(PID_FILE);
|
|
982
|
+
} catch {
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function isProcessAlive(pid) {
|
|
986
|
+
try {
|
|
987
|
+
process.kill(pid, 0);
|
|
988
|
+
return true;
|
|
989
|
+
} catch {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function isDaemonRunning() {
|
|
994
|
+
const pid = readPidFile();
|
|
995
|
+
if (pid === null) return { running: false };
|
|
996
|
+
if (isProcessAlive(pid)) {
|
|
997
|
+
return { running: true, pid };
|
|
998
|
+
}
|
|
999
|
+
removePidFile();
|
|
1000
|
+
return { running: false };
|
|
1001
|
+
}
|
|
1002
|
+
function setupSignalHandlers(server, logger) {
|
|
1003
|
+
const shutdown = async (signal) => {
|
|
1004
|
+
logger.log(`[DAEMON] Received ${signal}, shutting down...`);
|
|
1005
|
+
try {
|
|
1006
|
+
await server.stop();
|
|
1007
|
+
} catch {
|
|
1008
|
+
}
|
|
1009
|
+
removePidFile();
|
|
1010
|
+
logger.log("[DAEMON] Stopped.");
|
|
1011
|
+
process.exit(0);
|
|
1012
|
+
};
|
|
1013
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1014
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1015
|
+
process.on("uncaughtException", (err) => {
|
|
1016
|
+
logger.log(`[FATAL] Uncaught exception: ${err.message}`);
|
|
1017
|
+
removePidFile();
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
});
|
|
1020
|
+
process.on("unhandledRejection", (reason) => {
|
|
1021
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
1022
|
+
logger.log(`[FATAL] Unhandled rejection: ${message}`);
|
|
1023
|
+
removePidFile();
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
function forkDaemon(binPath, extraArgs = []) {
|
|
1028
|
+
const child = fork(binPath, ["start", "--_forked", ...extraArgs], {
|
|
1029
|
+
detached: true,
|
|
1030
|
+
stdio: "ignore"
|
|
1031
|
+
});
|
|
1032
|
+
child.unref();
|
|
1033
|
+
return child.pid;
|
|
1034
|
+
}
|
|
1035
|
+
function resolveBinPath(importMetaUrl) {
|
|
1036
|
+
return fileURLToPath(importMetaUrl);
|
|
1037
|
+
}
|
|
1038
|
+
function sleep(ms) {
|
|
1039
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/commands/start.ts
|
|
1043
|
+
async function startCommand(flags) {
|
|
1044
|
+
const isDaemon = flags.has("d") || flags.has("daemon");
|
|
1045
|
+
const isForked = flags.has("_forked");
|
|
1046
|
+
if (!isForked) {
|
|
1047
|
+
const state = isDaemonRunning();
|
|
1048
|
+
if (state.running) {
|
|
1049
|
+
console.error(`Liminal daemon is already running (PID ${state.pid}).`);
|
|
1050
|
+
console.error('Use "liminal stop" first, or "liminal status" to check.');
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (!isConfigured()) {
|
|
1055
|
+
console.error('Liminal is not configured. Run "liminal init" first.');
|
|
1056
|
+
process.exit(1);
|
|
1057
|
+
}
|
|
1058
|
+
let config = loadConfig();
|
|
1059
|
+
const portOverride = flags.get("port");
|
|
1060
|
+
const upstreamOverride = flags.get("upstream");
|
|
1061
|
+
config = applyOverrides(config, {
|
|
1062
|
+
...typeof portOverride === "string" ? { port: parseInt(portOverride, 10) } : {},
|
|
1063
|
+
...typeof upstreamOverride === "string" ? { upstreamBaseUrl: upstreamOverride } : {}
|
|
1064
|
+
});
|
|
1065
|
+
if (isDaemon && !isForked) {
|
|
1066
|
+
const extraArgs = [];
|
|
1067
|
+
if (portOverride) extraArgs.push("--port", String(portOverride));
|
|
1068
|
+
if (upstreamOverride) extraArgs.push("--upstream", String(upstreamOverride));
|
|
1069
|
+
const binPath = resolveBinPath(import.meta.url);
|
|
1070
|
+
const childPid = forkDaemon(binPath, extraArgs);
|
|
1071
|
+
console.log(`Liminal daemon started in background (PID ${childPid})`);
|
|
1072
|
+
console.log(`Proxy: http://127.0.0.1:${config.port}/v1`);
|
|
1073
|
+
console.log("Logs: ~/.liminal/logs/liminal.log");
|
|
1074
|
+
process.exit(0);
|
|
1075
|
+
}
|
|
1076
|
+
const isForeground = !isDaemon || isForked;
|
|
1077
|
+
const logger = new FileLogger({ mirrorStdout: isForeground && !isForked });
|
|
1078
|
+
const resolvedConfig = {
|
|
1079
|
+
rscApiKey: config.apiKey,
|
|
1080
|
+
rscBaseUrl: config.apiBaseUrl,
|
|
1081
|
+
proxyPort: config.port,
|
|
1082
|
+
compressionThreshold: config.compressionThreshold,
|
|
1083
|
+
compressRoles: config.compressRoles,
|
|
1084
|
+
learnFromResponses: config.learnFromResponses,
|
|
1085
|
+
latencyBudgetMs: config.latencyBudgetMs || void 0,
|
|
1086
|
+
upstreamBaseUrl: config.upstreamBaseUrl,
|
|
1087
|
+
anthropicUpstreamUrl: config.anthropicUpstreamUrl,
|
|
1088
|
+
enabled: config.enabled,
|
|
1089
|
+
tools: config.tools
|
|
1090
|
+
};
|
|
1091
|
+
const pipeline = new RSCPipelineWrapper({
|
|
1092
|
+
rscApiKey: config.apiKey,
|
|
1093
|
+
rscBaseUrl: config.apiBaseUrl,
|
|
1094
|
+
compressionThreshold: config.compressionThreshold,
|
|
1095
|
+
learnFromResponses: config.learnFromResponses,
|
|
1096
|
+
latencyBudgetMs: config.latencyBudgetMs || void 0
|
|
1097
|
+
});
|
|
1098
|
+
pipeline.events.on("compression", (event) => {
|
|
1099
|
+
if (event.tokensSaved > 0) {
|
|
1100
|
+
logger.log(`[LIMINAL] Compressed: ${event.tokensSaved} tokens saved (${event.ratio.toFixed(3)} ratio)`);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
pipeline.events.on("compression_skipped", (event) => {
|
|
1104
|
+
logger.log(`[LIMINAL] Skipped: ${event.reason}`);
|
|
1105
|
+
});
|
|
1106
|
+
pipeline.events.on("error", (event) => {
|
|
1107
|
+
logger.log(`[LIMINAL] Error: ${event.error.message}`);
|
|
1108
|
+
});
|
|
1109
|
+
pipeline.events.on("degradation", (event) => {
|
|
1110
|
+
logger.log(`[LIMINAL] Circuit ${event.circuitState}: ${event.reason}`);
|
|
1111
|
+
});
|
|
1112
|
+
const handler = createRequestHandler(pipeline, resolvedConfig, logger);
|
|
1113
|
+
const server = new ProxyServer(config.port, handler);
|
|
1114
|
+
setupSignalHandlers(server, logger);
|
|
1115
|
+
try {
|
|
1116
|
+
const actualPort = await server.start();
|
|
1117
|
+
writePidFile(process.pid);
|
|
1118
|
+
logger.log(`[DAEMON] Liminal proxy started on http://127.0.0.1:${actualPort}`);
|
|
1119
|
+
logger.log(`[DAEMON] Upstream (OpenAI): ${config.upstreamBaseUrl}`);
|
|
1120
|
+
logger.log(`[DAEMON] Upstream (Anthropic): ${config.anthropicUpstreamUrl}`);
|
|
1121
|
+
logger.log(`[DAEMON] Liminal API: ${config.apiBaseUrl}`);
|
|
1122
|
+
logger.log(`[DAEMON] PID: ${process.pid}`);
|
|
1123
|
+
if (isForeground && !isForked) {
|
|
1124
|
+
printBanner();
|
|
1125
|
+
console.log(` Liminal proxy running on http://127.0.0.1:${actualPort}/v1`);
|
|
1126
|
+
console.log(` Upstream: ${config.upstreamBaseUrl}`);
|
|
1127
|
+
console.log();
|
|
1128
|
+
console.log(" Point your AI tool's base URL here. Press Ctrl+C to stop.");
|
|
1129
|
+
console.log();
|
|
1130
|
+
}
|
|
1131
|
+
const healthy = await pipeline.healthCheck();
|
|
1132
|
+
if (healthy) {
|
|
1133
|
+
logger.log("[DAEMON] Liminal API health check: OK");
|
|
1134
|
+
} else {
|
|
1135
|
+
logger.log("[DAEMON] Liminal API health check: FAILED (will retry on first request)");
|
|
1136
|
+
}
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1139
|
+
console.error(`Failed to start proxy: ${message}`);
|
|
1140
|
+
process.exit(1);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/commands/stop.ts
|
|
1145
|
+
async function stopCommand() {
|
|
1146
|
+
const state = isDaemonRunning();
|
|
1147
|
+
if (!state.running || !state.pid) {
|
|
1148
|
+
console.log("Liminal daemon is not running.");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const pid = state.pid;
|
|
1152
|
+
console.log(`Stopping Liminal daemon (PID ${pid})...`);
|
|
1153
|
+
try {
|
|
1154
|
+
process.kill(pid, "SIGTERM");
|
|
1155
|
+
} catch {
|
|
1156
|
+
removePidFile();
|
|
1157
|
+
console.log("Liminal daemon stopped.");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
for (let i = 0; i < 25; i++) {
|
|
1161
|
+
await sleep(200);
|
|
1162
|
+
if (!isProcessAlive(pid)) {
|
|
1163
|
+
removePidFile();
|
|
1164
|
+
console.log(`Liminal daemon stopped (PID ${pid}).`);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
process.kill(pid, "SIGKILL");
|
|
1170
|
+
} catch {
|
|
1171
|
+
}
|
|
1172
|
+
removePidFile();
|
|
1173
|
+
console.log(`Liminal daemon force-killed (PID ${pid}).`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// src/commands/status.ts
|
|
1177
|
+
async function statusCommand() {
|
|
1178
|
+
if (!isConfigured()) {
|
|
1179
|
+
console.log("Liminal: not configured");
|
|
1180
|
+
console.log('Run "liminal init" to set up.');
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const state = isDaemonRunning();
|
|
1184
|
+
if (!state.running || !state.pid) {
|
|
1185
|
+
console.log("Liminal Daemon: stopped");
|
|
1186
|
+
console.log('Run "liminal start" to start the proxy.');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const config = loadConfig();
|
|
1190
|
+
const port = config.port;
|
|
1191
|
+
try {
|
|
1192
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
1193
|
+
signal: AbortSignal.timeout(3e3)
|
|
1194
|
+
});
|
|
1195
|
+
const data = await res.json();
|
|
1196
|
+
const uptime = formatUptime(data.uptime_ms);
|
|
1197
|
+
console.log(`Liminal Daemon: running (PID ${state.pid}, port ${port})`);
|
|
1198
|
+
console.log(`Circuit: ${data.circuit_state}`);
|
|
1199
|
+
console.log(`Session: ${data.session_id}`);
|
|
1200
|
+
console.log(`Uptime: ${uptime}`);
|
|
1201
|
+
if (data.session) {
|
|
1202
|
+
const s = data.session;
|
|
1203
|
+
const savingsPercent = s.tokens_processed > 0 ? (s.tokens_saved / s.tokens_processed * 100).toFixed(1) : "0.0";
|
|
1204
|
+
console.log();
|
|
1205
|
+
console.log(`Tokens: ${s.tokens_processed.toLocaleString()} processed, ${s.tokens_saved.toLocaleString()} saved (${savingsPercent}%)`);
|
|
1206
|
+
console.log(`Calls: ${s.calls_total} total (${s.calls_compressed} compressed, ${s.calls_skipped} skipped, ${s.calls_failed} failed)`);
|
|
1207
|
+
}
|
|
1208
|
+
} catch {
|
|
1209
|
+
console.log(`Liminal Daemon: running (PID ${state.pid}, port ${port})`);
|
|
1210
|
+
console.log("Circuit: unknown (could not reach /health)");
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function formatUptime(ms) {
|
|
1214
|
+
const seconds = Math.floor(ms / 1e3);
|
|
1215
|
+
const minutes = Math.floor(seconds / 60);
|
|
1216
|
+
const hours = Math.floor(minutes / 60);
|
|
1217
|
+
const days = Math.floor(hours / 24);
|
|
1218
|
+
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
1219
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
1220
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
1221
|
+
return `${seconds}s`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/commands/summary.ts
|
|
1225
|
+
async function summaryCommand() {
|
|
1226
|
+
if (!isConfigured()) {
|
|
1227
|
+
console.log('Liminal is not configured. Run "liminal init" first.');
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const state = isDaemonRunning();
|
|
1231
|
+
if (!state.running || !state.pid) {
|
|
1232
|
+
console.log('Liminal daemon is not running. Start it with "liminal start".');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const config = loadConfig();
|
|
1236
|
+
const port = config.port;
|
|
1237
|
+
try {
|
|
1238
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
1239
|
+
signal: AbortSignal.timeout(3e3)
|
|
1240
|
+
});
|
|
1241
|
+
const data = await res.json();
|
|
1242
|
+
const uptime = formatUptime2(data.uptime_ms);
|
|
1243
|
+
console.log();
|
|
1244
|
+
console.log(` Session: ${data.session_id}`);
|
|
1245
|
+
console.log(` Uptime: ${uptime}`);
|
|
1246
|
+
console.log();
|
|
1247
|
+
if (data.session) {
|
|
1248
|
+
const s = data.session;
|
|
1249
|
+
const savingsPercent = s.tokens_processed > 0 ? (s.tokens_saved / s.tokens_processed * 100).toFixed(1) : "0.0";
|
|
1250
|
+
const compressionRate = s.calls_total > 0 ? (s.calls_compressed / s.calls_total * 100).toFixed(0) : "0";
|
|
1251
|
+
console.log(" Compression:");
|
|
1252
|
+
console.log(` Tokens processed: ${s.tokens_processed.toLocaleString()}`);
|
|
1253
|
+
console.log(` Tokens saved: ${s.tokens_saved.toLocaleString()} (${savingsPercent}%)`);
|
|
1254
|
+
console.log(` Compression rate: ${compressionRate}% of calls compressed`);
|
|
1255
|
+
console.log(` Patterns learned: ${s.patterns_learned.toLocaleString()}`);
|
|
1256
|
+
console.log();
|
|
1257
|
+
console.log(" Calls:");
|
|
1258
|
+
console.log(` Total: ${s.calls_total}`);
|
|
1259
|
+
console.log(` Compressed: ${s.calls_compressed}`);
|
|
1260
|
+
console.log(` Skipped: ${s.calls_skipped}`);
|
|
1261
|
+
console.log(` Failed: ${s.calls_failed}`);
|
|
1262
|
+
console.log();
|
|
1263
|
+
console.log(" Cost Savings:");
|
|
1264
|
+
console.log(` Estimated: $${s.estimated_cost_saved_usd.toFixed(4)} USD`);
|
|
1265
|
+
console.log();
|
|
1266
|
+
} else {
|
|
1267
|
+
console.log(" No session data available yet.");
|
|
1268
|
+
console.log();
|
|
1269
|
+
}
|
|
1270
|
+
console.log(` Circuit: ${data.circuit_state}`);
|
|
1271
|
+
console.log(` API: ${config.apiBaseUrl}`);
|
|
1272
|
+
console.log(` Upstream: ${config.upstreamBaseUrl}`);
|
|
1273
|
+
console.log();
|
|
1274
|
+
} catch {
|
|
1275
|
+
console.error("Could not reach the Liminal daemon. Is it running?");
|
|
1276
|
+
console.error(`Tried http://127.0.0.1:${port}/health`);
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function formatUptime2(ms) {
|
|
1281
|
+
const seconds = Math.floor(ms / 1e3);
|
|
1282
|
+
const minutes = Math.floor(seconds / 60);
|
|
1283
|
+
const hours = Math.floor(minutes / 60);
|
|
1284
|
+
const days = Math.floor(hours / 24);
|
|
1285
|
+
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
1286
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
1287
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
1288
|
+
return `${seconds}s`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/commands/config.ts
|
|
1292
|
+
async function configCommand(flags) {
|
|
1293
|
+
const getKey = flags.get("get");
|
|
1294
|
+
const setKv = flags.get("set");
|
|
1295
|
+
if (typeof getKey === "string") {
|
|
1296
|
+
if (!isConfigured()) {
|
|
1297
|
+
console.error('Liminal is not configured. Run "liminal init" first.');
|
|
1298
|
+
process.exit(1);
|
|
1299
|
+
}
|
|
1300
|
+
const config2 = loadConfig();
|
|
1301
|
+
const value = config2[getKey];
|
|
1302
|
+
if (value === void 0) {
|
|
1303
|
+
console.error(`Unknown config key: ${getKey}`);
|
|
1304
|
+
process.exit(1);
|
|
1305
|
+
}
|
|
1306
|
+
console.log(getKey === "apiKey" ? maskApiKey(String(value)) : String(value));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (typeof setKv === "string") {
|
|
1310
|
+
const eqIdx = setKv.indexOf("=");
|
|
1311
|
+
if (eqIdx === -1) {
|
|
1312
|
+
console.error("Usage: liminal config --set key=value");
|
|
1313
|
+
process.exit(1);
|
|
1314
|
+
}
|
|
1315
|
+
const key = setKv.slice(0, eqIdx);
|
|
1316
|
+
const rawValue = setKv.slice(eqIdx + 1);
|
|
1317
|
+
if (!CONFIGURABLE_KEYS.has(key) && key !== "apiKey") {
|
|
1318
|
+
console.error(`Unknown or non-configurable key: ${key}`);
|
|
1319
|
+
console.error(`Configurable keys: ${[...CONFIGURABLE_KEYS].join(", ")}`);
|
|
1320
|
+
process.exit(1);
|
|
1321
|
+
}
|
|
1322
|
+
let parsedValue = rawValue;
|
|
1323
|
+
if (key === "port" || key === "compressionThreshold" || key === "latencyBudgetMs") {
|
|
1324
|
+
parsedValue = parseInt(rawValue, 10);
|
|
1325
|
+
if (isNaN(parsedValue)) {
|
|
1326
|
+
console.error(`Invalid number for ${key}: ${rawValue}`);
|
|
1327
|
+
process.exit(1);
|
|
1328
|
+
}
|
|
1329
|
+
} else if (key === "learnFromResponses" || key === "enabled") {
|
|
1330
|
+
parsedValue = rawValue === "true" || rawValue === "1";
|
|
1331
|
+
} else if (key === "compressRoles") {
|
|
1332
|
+
parsedValue = rawValue.split(",").map((s) => s.trim());
|
|
1333
|
+
}
|
|
1334
|
+
saveConfig({ [key]: parsedValue });
|
|
1335
|
+
console.log(`Set ${key} = ${key === "apiKey" ? maskApiKey(rawValue) : rawValue}`);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (!isConfigured()) {
|
|
1339
|
+
console.log(`Liminal is not configured. Run "liminal init" first.`);
|
|
1340
|
+
console.log(`Config file: ${CONFIG_FILE}`);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const config = loadConfig();
|
|
1344
|
+
console.log();
|
|
1345
|
+
console.log(` Config: ${CONFIG_FILE}`);
|
|
1346
|
+
console.log();
|
|
1347
|
+
console.log(` apiKey: ${maskApiKey(config.apiKey)}`);
|
|
1348
|
+
console.log(` apiBaseUrl: ${config.apiBaseUrl}`);
|
|
1349
|
+
console.log(` upstreamBaseUrl: ${config.upstreamBaseUrl}`);
|
|
1350
|
+
console.log(` anthropicUpstreamUrl: ${config.anthropicUpstreamUrl}`);
|
|
1351
|
+
console.log(` tools: ${config.tools.length > 0 ? config.tools.join(", ") : "(none)"}`);
|
|
1352
|
+
console.log(` port: ${config.port}`);
|
|
1353
|
+
console.log(` compressionThreshold: ${config.compressionThreshold}`);
|
|
1354
|
+
console.log(` compressRoles: ${config.compressRoles.join(", ")}`);
|
|
1355
|
+
console.log(` learnFromResponses: ${config.learnFromResponses}`);
|
|
1356
|
+
console.log(` latencyBudgetMs: ${config.latencyBudgetMs || "auto"}`);
|
|
1357
|
+
console.log(` enabled: ${config.enabled}`);
|
|
1358
|
+
console.log();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/commands/logs.ts
|
|
1362
|
+
import { readFileSync as readFileSync3, existsSync as existsSync4, statSync as statSync2, createReadStream } from "fs";
|
|
1363
|
+
import { watchFile, unwatchFile } from "fs";
|
|
1364
|
+
async function logsCommand(flags) {
|
|
1365
|
+
const follow = flags.has("follow") || flags.has("f");
|
|
1366
|
+
const linesFlag = flags.get("lines") ?? flags.get("n");
|
|
1367
|
+
const lines = typeof linesFlag === "string" ? parseInt(linesFlag, 10) : 50;
|
|
1368
|
+
if (!existsSync4(LOG_FILE)) {
|
|
1369
|
+
console.log('No log file found. Start the daemon with "liminal start" to generate logs.');
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const content = readFileSync3(LOG_FILE, "utf-8");
|
|
1373
|
+
const allLines = content.split("\n");
|
|
1374
|
+
const tail = allLines.slice(-lines - 1);
|
|
1375
|
+
process.stdout.write(tail.join("\n"));
|
|
1376
|
+
if (!follow) return;
|
|
1377
|
+
let lastSize = statSync2(LOG_FILE).size;
|
|
1378
|
+
watchFile(LOG_FILE, { interval: 500 }, (curr) => {
|
|
1379
|
+
if (curr.size > lastSize) {
|
|
1380
|
+
const stream = createReadStream(LOG_FILE, { start: lastSize, encoding: "utf-8" });
|
|
1381
|
+
stream.on("data", (chunk) => process.stdout.write(chunk));
|
|
1382
|
+
stream.on("end", () => {
|
|
1383
|
+
lastSize = curr.size;
|
|
1384
|
+
});
|
|
1385
|
+
} else if (curr.size < lastSize) {
|
|
1386
|
+
lastSize = 0;
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
process.on("SIGINT", () => {
|
|
1390
|
+
unwatchFile(LOG_FILE);
|
|
1391
|
+
process.exit(0);
|
|
1392
|
+
});
|
|
1393
|
+
await new Promise(() => {
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// src/bin.ts
|
|
1398
|
+
var USAGE = `
|
|
1399
|
+
liminal v${VERSION} \u2014 Transparent LLM context compression proxy
|
|
1400
|
+
|
|
1401
|
+
Usage:
|
|
1402
|
+
liminal init Set up Liminal (API key, config)
|
|
1403
|
+
liminal start [-d] [--port PORT] Start the compression proxy
|
|
1404
|
+
liminal stop Stop the running proxy
|
|
1405
|
+
liminal status Show proxy health and stats
|
|
1406
|
+
liminal summary Detailed session metrics
|
|
1407
|
+
liminal config [--set k=v] [--get k] View or edit configuration
|
|
1408
|
+
liminal logs [--follow] [--lines N] View proxy logs
|
|
1409
|
+
|
|
1410
|
+
Options:
|
|
1411
|
+
-h, --help Show this help message
|
|
1412
|
+
-v, --version Show version number
|
|
1413
|
+
|
|
1414
|
+
Getting started:
|
|
1415
|
+
1. liminal init # Enter your API key + select tools
|
|
1416
|
+
2. liminal start # Start the proxy
|
|
1417
|
+
3. Connect your AI tools:
|
|
1418
|
+
Claude Code: export ANTHROPIC_BASE_URL=http://localhost:3141
|
|
1419
|
+
Codex: export OPENAI_BASE_URL=http://localhost:3141/v1
|
|
1420
|
+
Cursor: Settings > Models > Base URL > http://localhost:3141/v1
|
|
1421
|
+
`;
|
|
1422
|
+
function parseArgs(argv) {
|
|
1423
|
+
const command = argv[2] ?? "";
|
|
1424
|
+
const flags = /* @__PURE__ */ new Map();
|
|
1425
|
+
for (let i = 3; i < argv.length; i++) {
|
|
1426
|
+
const arg = argv[i];
|
|
1427
|
+
if (arg.startsWith("--")) {
|
|
1428
|
+
const key = arg.slice(2);
|
|
1429
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
1430
|
+
flags.set(key, argv[i + 1]);
|
|
1431
|
+
i++;
|
|
1432
|
+
} else {
|
|
1433
|
+
flags.set(key, true);
|
|
1434
|
+
}
|
|
1435
|
+
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
1436
|
+
const key = arg.slice(1);
|
|
1437
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
1438
|
+
flags.set(key, argv[i + 1]);
|
|
1439
|
+
i++;
|
|
1440
|
+
} else {
|
|
1441
|
+
flags.set(key, true);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return { command, flags };
|
|
1446
|
+
}
|
|
1447
|
+
async function main() {
|
|
1448
|
+
const { command, flags } = parseArgs(process.argv);
|
|
1449
|
+
if (flags.has("h") || flags.has("help") || command === "help" || command === "--help" || command === "-h") {
|
|
1450
|
+
console.log(USAGE);
|
|
1451
|
+
process.exit(0);
|
|
1452
|
+
}
|
|
1453
|
+
if (flags.has("v") || flags.has("version") || command === "version" || command === "--version" || command === "-v") {
|
|
1454
|
+
console.log(VERSION);
|
|
1455
|
+
process.exit(0);
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
switch (command) {
|
|
1459
|
+
case "init":
|
|
1460
|
+
await initCommand();
|
|
1461
|
+
break;
|
|
1462
|
+
case "start":
|
|
1463
|
+
await startCommand(flags);
|
|
1464
|
+
break;
|
|
1465
|
+
case "stop":
|
|
1466
|
+
await stopCommand();
|
|
1467
|
+
break;
|
|
1468
|
+
case "status":
|
|
1469
|
+
await statusCommand();
|
|
1470
|
+
break;
|
|
1471
|
+
case "summary":
|
|
1472
|
+
await summaryCommand();
|
|
1473
|
+
break;
|
|
1474
|
+
case "config":
|
|
1475
|
+
await configCommand(flags);
|
|
1476
|
+
break;
|
|
1477
|
+
case "logs":
|
|
1478
|
+
await logsCommand(flags);
|
|
1479
|
+
break;
|
|
1480
|
+
case "":
|
|
1481
|
+
console.log(USAGE);
|
|
1482
|
+
process.exit(0);
|
|
1483
|
+
break;
|
|
1484
|
+
default:
|
|
1485
|
+
console.error(`Unknown command: ${command}`);
|
|
1486
|
+
console.log(USAGE);
|
|
1487
|
+
process.exit(1);
|
|
1488
|
+
}
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1491
|
+
console.error(`Error: ${message}`);
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
main();
|
|
1496
|
+
//# sourceMappingURL=bin.js.map
|