lynkr 9.0.2 → 9.1.3
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/README.md +21 -10
- package/bin/cli.js +18 -1
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/package.json +4 -2
- package/public/dashboard.html +665 -0
- package/scripts/build-knn-index.js +130 -0
- package/scripts/calibrate-thresholds.js +197 -0
- package/scripts/compare-policies.js +67 -0
- package/scripts/learn-output-ratios.js +162 -0
- package/scripts/refresh-pricing.js +122 -0
- package/scripts/run-routerarena.js +26 -0
- package/scripts/sample-regret.js +84 -0
- package/scripts/train-risk-classifier.js +191 -0
- package/src/api/files-router.js +6 -6
- package/src/api/middleware/budget-enforcer.js +60 -0
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/middleware/tenant.js +21 -0
- package/src/api/openai-router.js +1 -1
- package/src/api/router.js +204 -87
- package/src/budget/hierarchical-budget.js +159 -0
- package/src/cache/semantic.js +28 -2
- package/src/clients/databricks.js +68 -10
- package/src/clients/openai-format.js +31 -5
- package/src/config/index.js +246 -43
- package/src/context/toon.js +5 -4
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +106 -11
- package/src/orchestrator/preflight.js +188 -0
- package/src/prompts/system.js +34 -6
- package/src/routing/bandit.js +246 -0
- package/src/routing/cascade.js +106 -0
- package/src/routing/complexity-analyzer.js +7 -15
- package/src/routing/confidence-scorer.js +121 -0
- package/src/routing/context-validator.js +71 -0
- package/src/routing/cost-optimizer.js +5 -2
- package/src/routing/deadline.js +52 -0
- package/src/routing/drift-monitor.js +113 -0
- package/src/routing/embedding-cache.js +77 -0
- package/src/routing/index.js +374 -4
- package/src/routing/interaction.js +183 -0
- package/src/routing/knn-router.js +206 -0
- package/src/routing/latency-tracker.js +113 -71
- package/src/routing/model-tiers.js +156 -6
- package/src/routing/output-ratios.js +57 -0
- package/src/routing/regret-estimator.js +91 -0
- package/src/routing/reward-pipeline.js +62 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/risk-classifier.js +130 -0
- package/src/routing/shadow-mode.js +77 -0
- package/src/routing/telemetry.js +7 -0
- package/src/routing/tenant-policy.js +96 -0
- package/src/routing/tokenizer.js +162 -0
- package/src/server.js +12 -0
- package/src/stores/file-store.js +42 -7
- package/src/tools/smart-selection.js +11 -2
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant routing policy (Phase 6.1).
|
|
3
|
+
*
|
|
4
|
+
* Each tenant can override:
|
|
5
|
+
* - tier thresholds (which complexity scores map to which tiers)
|
|
6
|
+
* - reward weights (λ for cost, μ for latency in the bandit)
|
|
7
|
+
* - max acceptable latency
|
|
8
|
+
* - blocked models (never route to these)
|
|
9
|
+
*
|
|
10
|
+
* Tenant id is read from the `LYNKR_TENANT_ID` request header. Per-tenant
|
|
11
|
+
* configs live in data/tenants/<id>.json. Falls back to global config when
|
|
12
|
+
* the id is absent or the file doesn't exist.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const logger = require('../logger');
|
|
18
|
+
|
|
19
|
+
const TENANTS_DIR = path.join(__dirname, '../../data/tenants');
|
|
20
|
+
const _cache = new Map();
|
|
21
|
+
const RELOAD_INTERVAL_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
function _loadTenant(tenantId) {
|
|
24
|
+
if (!tenantId) return null;
|
|
25
|
+
const cached = _cache.get(tenantId);
|
|
26
|
+
if (cached && Date.now() - cached.loadedAt < RELOAD_INTERVAL_MS) return cached.config;
|
|
27
|
+
|
|
28
|
+
const file = path.join(TENANTS_DIR, `${tenantId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
|
29
|
+
if (!fs.existsSync(file)) {
|
|
30
|
+
_cache.set(tenantId, { config: null, loadedAt: Date.now() });
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
35
|
+
_cache.set(tenantId, { config: data, loadedAt: Date.now() });
|
|
36
|
+
return data;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.warn({ tenantId, err: err.message }, '[TenantPolicy] Load failed');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getPolicy(tenantId) {
|
|
44
|
+
const t = _loadTenant(tenantId);
|
|
45
|
+
if (!t) return null;
|
|
46
|
+
return {
|
|
47
|
+
tenantId,
|
|
48
|
+
tierRanges: t.tierRanges || null,
|
|
49
|
+
rewardWeights: t.rewardWeights || null,
|
|
50
|
+
maxLatencyMs: t.maxLatencyMs ?? null,
|
|
51
|
+
blockedModels: Array.isArray(t.blockedModels) ? new Set(t.blockedModels) : null,
|
|
52
|
+
preferredProviders: Array.isArray(t.preferredProviders) ? t.preferredProviders : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply tenant overrides to a routing decision after the main algorithm has
|
|
58
|
+
* produced one. Returns either the decision unchanged or a new decision
|
|
59
|
+
* respecting the tenant constraints.
|
|
60
|
+
*/
|
|
61
|
+
function applyTenantOverrides(decision, tenantPolicy) {
|
|
62
|
+
if (!tenantPolicy || !decision) return decision;
|
|
63
|
+
// Blocked model → fall back to next-cheapest qualifying model in same tier
|
|
64
|
+
if (tenantPolicy.blockedModels && decision.model && tenantPolicy.blockedModels.has(decision.model)) {
|
|
65
|
+
const { getCostOptimizer } = require('./cost-optimizer');
|
|
66
|
+
const optimizer = getCostOptimizer();
|
|
67
|
+
const cheapest = optimizer.findCheapestForTier(decision.tier, tenantPolicy.preferredProviders || []);
|
|
68
|
+
if (cheapest && !tenantPolicy.blockedModels.has(cheapest.model)) {
|
|
69
|
+
return {
|
|
70
|
+
...decision,
|
|
71
|
+
provider: cheapest.provider,
|
|
72
|
+
model: cheapest.model,
|
|
73
|
+
method: (decision.method || '') + '+tenant_override',
|
|
74
|
+
tenantOverride: { reason: 'blocked_model', tenantId: tenantPolicy.tenantId },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return decision;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getTenantId(req) {
|
|
82
|
+
if (!req) return null;
|
|
83
|
+
const h = req.headers || req;
|
|
84
|
+
return (h['lynkr-tenant-id'] || h['LYNKR-Tenant-Id'] || h['x-tenant-id'] || null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reloadCache() {
|
|
88
|
+
_cache.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
getPolicy,
|
|
93
|
+
getTenantId,
|
|
94
|
+
applyTenantOverrides,
|
|
95
|
+
reloadCache,
|
|
96
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accurate token estimation using js-tiktoken.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the chars/4 approximation across the routing path. Falls back to
|
|
5
|
+
* chars/4 if js-tiktoken is unavailable (graceful degradation — never throws).
|
|
6
|
+
*
|
|
7
|
+
* Phase 1.1 of the routing overhaul.
|
|
8
|
+
*
|
|
9
|
+
* @module routing/tokenizer
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const logger = require('../logger');
|
|
13
|
+
|
|
14
|
+
let _tiktoken = null;
|
|
15
|
+
let _tiktokenLoaded = false;
|
|
16
|
+
const _encoderCache = new Map();
|
|
17
|
+
|
|
18
|
+
function _loadTiktoken() {
|
|
19
|
+
if (_tiktokenLoaded) return _tiktoken;
|
|
20
|
+
_tiktokenLoaded = true;
|
|
21
|
+
try {
|
|
22
|
+
_tiktoken = require('js-tiktoken');
|
|
23
|
+
} catch (err) {
|
|
24
|
+
logger.debug(
|
|
25
|
+
{ err: err.message },
|
|
26
|
+
'[Tokenizer] js-tiktoken not available, falling back to chars/4'
|
|
27
|
+
);
|
|
28
|
+
_tiktoken = null;
|
|
29
|
+
}
|
|
30
|
+
return _tiktoken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _encodingForModel(model) {
|
|
34
|
+
if (!model || typeof model !== 'string') return 'cl100k_base';
|
|
35
|
+
const lower = model.toLowerCase();
|
|
36
|
+
// GPT-4o family + o-series use o200k_base
|
|
37
|
+
if (
|
|
38
|
+
lower.includes('gpt-4o') ||
|
|
39
|
+
lower.includes('gpt-4.1') ||
|
|
40
|
+
lower.includes('gpt-5') ||
|
|
41
|
+
lower.includes('o1') ||
|
|
42
|
+
lower.includes('o3') ||
|
|
43
|
+
lower.includes('o4')
|
|
44
|
+
) {
|
|
45
|
+
return 'o200k_base';
|
|
46
|
+
}
|
|
47
|
+
// GPT-4 / GPT-3.5 / Anthropic / most others approximate well with cl100k_base
|
|
48
|
+
return 'cl100k_base';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _getEncoder(model) {
|
|
52
|
+
const tiktoken = _loadTiktoken();
|
|
53
|
+
if (!tiktoken) return null;
|
|
54
|
+
const encName = _encodingForModel(model);
|
|
55
|
+
let cached = _encoderCache.get(encName);
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
try {
|
|
58
|
+
cached = tiktoken.getEncoding(encName);
|
|
59
|
+
_encoderCache.set(encName, cached);
|
|
60
|
+
return cached;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.debug(
|
|
63
|
+
{ err: err.message, encoding: encName },
|
|
64
|
+
'[Tokenizer] Encoder load failed, using fallback'
|
|
65
|
+
);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Count tokens in a single string.
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @param {string|null} model - optional model name for encoding selection
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
function countTokens(text, model = null) {
|
|
77
|
+
if (!text || typeof text !== 'string') return 0;
|
|
78
|
+
const encoder = _getEncoder(model);
|
|
79
|
+
if (!encoder) return Math.ceil(text.length / 4);
|
|
80
|
+
try {
|
|
81
|
+
return encoder.encode(text).length;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return Math.ceil(text.length / 4);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _extractText(content) {
|
|
88
|
+
if (!content) return '';
|
|
89
|
+
if (typeof content === 'string') return content;
|
|
90
|
+
if (Array.isArray(content)) {
|
|
91
|
+
let combined = '';
|
|
92
|
+
for (const block of content) {
|
|
93
|
+
if (!block) continue;
|
|
94
|
+
if (typeof block === 'string') {
|
|
95
|
+
combined += block + ' ';
|
|
96
|
+
} else if (block.type === 'text' && block.text) {
|
|
97
|
+
combined += block.text + ' ';
|
|
98
|
+
} else if (typeof block.text === 'string') {
|
|
99
|
+
combined += block.text + ' ';
|
|
100
|
+
} else if (block.type === 'tool_use' && block.input) {
|
|
101
|
+
try {
|
|
102
|
+
combined += JSON.stringify(block.input) + ' ';
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore non-serializable input
|
|
105
|
+
}
|
|
106
|
+
} else if (block.type === 'tool_result' && block.content) {
|
|
107
|
+
combined += _extractText(block.content) + ' ';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return combined;
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _imageTokenEstimate(content) {
|
|
116
|
+
if (!Array.isArray(content)) return 0;
|
|
117
|
+
let imageBase64Bytes = 0;
|
|
118
|
+
for (const block of content) {
|
|
119
|
+
if (block?.type === 'image' && block.source?.data) {
|
|
120
|
+
imageBase64Bytes += block.source.data.length;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Rough heuristic mirroring previous behavior: ~1 token per 6 base64 chars
|
|
124
|
+
return Math.floor(imageBase64Bytes / 6);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Count tokens across a full Anthropic-format message array + optional system.
|
|
129
|
+
* @param {Array} messages
|
|
130
|
+
* @param {string|Array|null} system
|
|
131
|
+
* @param {string|null} model
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
function countMessagesTokens(messages = [], system = null, model = null) {
|
|
135
|
+
let total = 0;
|
|
136
|
+
if (system) {
|
|
137
|
+
total += countTokens(_extractText(system), model);
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(messages)) {
|
|
140
|
+
for (const msg of messages) {
|
|
141
|
+
total += countTokens(_extractText(msg?.content), model);
|
|
142
|
+
total += _imageTokenEstimate(msg?.content);
|
|
143
|
+
}
|
|
144
|
+
// Per-message structural overhead (~4 tokens per message in both Anthropic and OpenAI)
|
|
145
|
+
total += messages.length * 4;
|
|
146
|
+
}
|
|
147
|
+
return total;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Count tokens from a full payload object (Anthropic-style with .messages, .system, .model).
|
|
152
|
+
*/
|
|
153
|
+
function countPayloadTokens(payload, model = null) {
|
|
154
|
+
if (!payload) return 0;
|
|
155
|
+
return countMessagesTokens(payload.messages, payload.system, model || payload.model);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
countTokens,
|
|
160
|
+
countMessagesTokens,
|
|
161
|
+
countPayloadTokens,
|
|
162
|
+
};
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,8 @@ const { metricsMiddleware } = require("./api/middleware/metrics");
|
|
|
9
9
|
const { requestLoggingMiddleware } = require("./api/middleware/request-logging");
|
|
10
10
|
const { errorHandlingMiddleware, notFoundHandler } = require("./api/middleware/error-handling");
|
|
11
11
|
const { loadSheddingMiddleware, initializeLoadShedder } = require("./api/middleware/load-shedding");
|
|
12
|
+
const { tenantMiddleware } = require("./api/middleware/tenant");
|
|
13
|
+
const { budgetEnforcer } = require("./api/middleware/budget-enforcer");
|
|
12
14
|
const { livenessCheck, readinessCheck } = require("./api/health");
|
|
13
15
|
const { getMetricsCollector } = require("./observability/metrics");
|
|
14
16
|
const { getShutdownManager } = require("./server/shutdown");
|
|
@@ -90,6 +92,13 @@ function createApp() {
|
|
|
90
92
|
app.use('/v1/messages', budgetMiddleware);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// Phase 6.1 — per-tenant routing policies (LYNKR-Tenant-Id header).
|
|
96
|
+
// Runs before message handling so res.locals.tenantPolicy is populated.
|
|
97
|
+
app.use('/v1/messages', tenantMiddleware);
|
|
98
|
+
|
|
99
|
+
// Phase 6.2 — hierarchical budget enforcement (LYNKR_BUDGET_ENFORCER=false to disable).
|
|
100
|
+
app.use('/v1/messages', budgetEnforcer);
|
|
101
|
+
|
|
93
102
|
// Health check endpoints
|
|
94
103
|
app.get("/health/live", livenessCheck);
|
|
95
104
|
app.get("/health/ready", readinessCheck);
|
|
@@ -147,6 +156,9 @@ function createApp() {
|
|
|
147
156
|
|
|
148
157
|
app.use(router);
|
|
149
158
|
|
|
159
|
+
// Dashboard UI
|
|
160
|
+
app.use('/dashboard', require('./dashboard/router'));
|
|
161
|
+
|
|
150
162
|
// Files API
|
|
151
163
|
const filesRouter = require("./api/files-router");
|
|
152
164
|
app.use("/v1", filesRouter);
|
package/src/stores/file-store.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const fsp = require("fs").promises;
|
|
2
3
|
const path = require("path");
|
|
3
4
|
const crypto = require("crypto");
|
|
4
5
|
const logger = require("../logger");
|
|
5
6
|
|
|
6
7
|
const STORAGE_DIR = path.resolve(process.env.FILES_STORAGE_PATH || "./data/files");
|
|
8
|
+
const METADATA_FILE = path.join(STORAGE_DIR, "_metadata.json");
|
|
7
9
|
const MAX_FILES = parseInt(process.env.FILES_MAX_COUNT || "1000", 10);
|
|
8
10
|
|
|
9
11
|
const metadata = new Map();
|
|
@@ -14,15 +16,46 @@ function ensureStorageDir() {
|
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
function
|
|
19
|
+
function persistMetadata() {
|
|
20
|
+
try {
|
|
21
|
+
const entries = Array.from(metadata.values());
|
|
22
|
+
fs.writeFileSync(METADATA_FILE, JSON.stringify(entries), "utf8");
|
|
23
|
+
} catch (err) {
|
|
24
|
+
logger.warn({ err: err.message }, "Failed to persist file metadata");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadMetadata() {
|
|
29
|
+
ensureStorageDir();
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(METADATA_FILE)) return;
|
|
32
|
+
const entries = JSON.parse(fs.readFileSync(METADATA_FILE, "utf8"));
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
// Only restore entries whose backing file still exists on disk
|
|
35
|
+
if (fs.existsSync(entry.storage_path)) {
|
|
36
|
+
metadata.set(entry.id, entry);
|
|
37
|
+
} else {
|
|
38
|
+
logger.debug({ fileId: entry.id }, "Dropping orphaned metadata entry (file missing)");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
logger.info({ count: metadata.size }, "File metadata restored from disk");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.warn({ err: err.message }, "Could not load file metadata; starting fresh");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Restore metadata at module load so restarts don't orphan files
|
|
48
|
+
loadMetadata();
|
|
49
|
+
|
|
50
|
+
async function storeFile(buffer, { filename, purpose, mimeType }) {
|
|
18
51
|
ensureStorageDir();
|
|
19
52
|
if (metadata.size >= MAX_FILES) {
|
|
20
53
|
const oldest = metadata.keys().next().value;
|
|
21
|
-
deleteFile(oldest);
|
|
54
|
+
await deleteFile(oldest);
|
|
22
55
|
}
|
|
23
56
|
const id = `file-${crypto.randomUUID()}`;
|
|
24
57
|
const storagePath = path.join(STORAGE_DIR, id);
|
|
25
|
-
|
|
58
|
+
await fsp.writeFile(storagePath, buffer);
|
|
26
59
|
const entry = {
|
|
27
60
|
id,
|
|
28
61
|
object: "file",
|
|
@@ -34,6 +67,7 @@ function storeFile(buffer, { filename, purpose, mimeType }) {
|
|
|
34
67
|
storage_path: storagePath,
|
|
35
68
|
};
|
|
36
69
|
metadata.set(id, entry);
|
|
70
|
+
persistMetadata();
|
|
37
71
|
logger.info({ fileId: id, bytes: buffer.length, filename }, "File stored");
|
|
38
72
|
return entry;
|
|
39
73
|
}
|
|
@@ -42,21 +76,22 @@ function getFile(id) {
|
|
|
42
76
|
return metadata.get(id) || null;
|
|
43
77
|
}
|
|
44
78
|
|
|
45
|
-
function getFileContent(id) {
|
|
79
|
+
async function getFileContent(id) {
|
|
46
80
|
const entry = metadata.get(id);
|
|
47
81
|
if (!entry) return null;
|
|
48
82
|
try {
|
|
49
|
-
return
|
|
83
|
+
return await fsp.readFile(entry.storage_path);
|
|
50
84
|
} catch {
|
|
51
85
|
return null;
|
|
52
86
|
}
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
function deleteFile(id) {
|
|
89
|
+
async function deleteFile(id) {
|
|
56
90
|
const entry = metadata.get(id);
|
|
57
91
|
if (!entry) return false;
|
|
58
|
-
try {
|
|
92
|
+
try { await fsp.unlink(entry.storage_path); } catch {}
|
|
59
93
|
metadata.delete(id);
|
|
94
|
+
persistMetadata();
|
|
60
95
|
return true;
|
|
61
96
|
}
|
|
62
97
|
|
|
@@ -280,9 +280,18 @@ function selectToolsSmartly(tools, classification, options = {}) {
|
|
|
280
280
|
|
|
281
281
|
// Get relevant tool names for this request type
|
|
282
282
|
const relevantToolNames = TOOL_SELECTION_MAP[requestType] || TOOL_SELECTION_MAP.coding;
|
|
283
|
+
const relevantLower = new Set(relevantToolNames.map(n => n.toLowerCase()));
|
|
283
284
|
|
|
284
|
-
// Filter to relevant tools only
|
|
285
|
-
|
|
285
|
+
// Filter to relevant tools only (case-insensitive match so external clients
|
|
286
|
+
// using lowercase names like Pi's `bash`/`read` aren't filtered out)
|
|
287
|
+
let selectedTools = tools.filter(tool => relevantLower.has(String(tool.name || '').toLowerCase()));
|
|
288
|
+
|
|
289
|
+
// If nothing matched, the caller is using a tool ecosystem we don't recognize
|
|
290
|
+
// (e.g. Pi's read/write/edit/bash). Pass tools through untouched rather than
|
|
291
|
+
// deleting them — otherwise the LLM gets no schema and hallucinates defaults.
|
|
292
|
+
if (selectedTools.length === 0) {
|
|
293
|
+
return tools;
|
|
294
|
+
}
|
|
286
295
|
|
|
287
296
|
// Mode-specific adjustments
|
|
288
297
|
if (config.mode === 'aggressive') {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Compressor
|
|
3
|
+
*
|
|
4
|
+
* Reads completed agent sessions out of Lynkr's session DB,
|
|
5
|
+
* joins with routing telemetry to pick up tier / score / outcome
|
|
6
|
+
* metadata, and emits JSONL training samples for fine-tuning small
|
|
7
|
+
* models on tool-call generation and tier-routing decisions.
|
|
8
|
+
*
|
|
9
|
+
* Each line of the output JSONL is a self-contained sample:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "session_id": "...",
|
|
13
|
+
* "messages": [{"role": "...", "content": ...}, ...],
|
|
14
|
+
* "tool_calls": [...],
|
|
15
|
+
* "outcome": "success" | "error",
|
|
16
|
+
* "tier": "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING",
|
|
17
|
+
* "complexity_score": 38,
|
|
18
|
+
* "model_used": "gpt-4o",
|
|
19
|
+
* "provider_used": "azure-openai",
|
|
20
|
+
* "tokens_in": 1234,
|
|
21
|
+
* "tokens_out": 456,
|
|
22
|
+
* "latency_ms": 2400,
|
|
23
|
+
* "started_at": "2026-05-03T10:11:12Z",
|
|
24
|
+
* "ended_at": "2026-05-03T10:11:14Z"
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* The compressor is read-only — it never modifies the source DBs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require("fs");
|
|
31
|
+
const path = require("path");
|
|
32
|
+
|
|
33
|
+
const db = require("../db");
|
|
34
|
+
const telemetry = require("../routing/telemetry");
|
|
35
|
+
|
|
36
|
+
// Patterns for the optional --anonymize pass. Order matters: more
|
|
37
|
+
// specific patterns first so they don't get clobbered by generic ones.
|
|
38
|
+
const ANONYMIZE_PATTERNS = [
|
|
39
|
+
// API keys and bearer tokens
|
|
40
|
+
[/sk-[A-Za-z0-9_-]{20,}/g, "<API_KEY>"],
|
|
41
|
+
[/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer <REDACTED>"],
|
|
42
|
+
[/dapi_[A-Za-z0-9_-]+/g, "<DATABRICKS_KEY>"],
|
|
43
|
+
[/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<JWT>"],
|
|
44
|
+
// AWS keys
|
|
45
|
+
[/AKIA[0-9A-Z]{16}/g, "<AWS_ACCESS_KEY>"],
|
|
46
|
+
// Email addresses
|
|
47
|
+
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<EMAIL>"],
|
|
48
|
+
// Absolute filesystem paths under /Users/<name>/ or /home/<name>/
|
|
49
|
+
[/\/Users\/[^/\s]+/g, "/Users/<USER>"],
|
|
50
|
+
[/\/home\/[^/\s]+/g, "/home/<USER>"],
|
|
51
|
+
// IPs
|
|
52
|
+
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "<IP>"],
|
|
53
|
+
// Hostnames containing service-now / corporate domains (configurable)
|
|
54
|
+
[/[A-Za-z0-9-]+\.service-now\.com/gi, "<SERVICENOW_HOST>"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function anonymize(value) {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
let out = value;
|
|
60
|
+
for (const [re, replacement] of ANONYMIZE_PATTERNS) {
|
|
61
|
+
out = out.replace(re, replacement);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(value)) return value.map(anonymize);
|
|
66
|
+
if (value && typeof value === "object") {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(value)) out[k] = anonymize(v);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJsonSafe(text, fallback = null) {
|
|
75
|
+
if (text == null) return fallback;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List session ids in a window, optionally filtered by tier.
|
|
85
|
+
*/
|
|
86
|
+
function listSessions({ since = null, tier = null } = {}) {
|
|
87
|
+
if (!db) return [];
|
|
88
|
+
|
|
89
|
+
const rows = since
|
|
90
|
+
? db
|
|
91
|
+
.prepare(
|
|
92
|
+
"SELECT id, created_at, updated_at, metadata FROM sessions WHERE updated_at >= ? ORDER BY updated_at DESC"
|
|
93
|
+
)
|
|
94
|
+
.all(since)
|
|
95
|
+
: db.prepare("SELECT id, created_at, updated_at, metadata FROM sessions ORDER BY updated_at DESC").all();
|
|
96
|
+
|
|
97
|
+
if (!tier) return rows;
|
|
98
|
+
|
|
99
|
+
// Tier filter requires joining against routing telemetry — we do that
|
|
100
|
+
// per-session lazily so we don't pre-load the whole telemetry table.
|
|
101
|
+
return rows.filter((s) => sessionTier(s.id) === tier);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find the dominant tier picked across a session's telemetry rows.
|
|
106
|
+
*/
|
|
107
|
+
function sessionTier(sessionId) {
|
|
108
|
+
try {
|
|
109
|
+
const rows = telemetry.query({ session_id: sessionId, limit: 1000 });
|
|
110
|
+
if (rows.length === 0) return null;
|
|
111
|
+
const counts = {};
|
|
112
|
+
for (const r of rows) counts[r.tier || "UNKNOWN"] = (counts[r.tier || "UNKNOWN"] || 0) + 1;
|
|
113
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build one trajectory record for a single session.
|
|
121
|
+
*/
|
|
122
|
+
function buildTrajectory(session, options = {}) {
|
|
123
|
+
if (!db) return null;
|
|
124
|
+
|
|
125
|
+
const historyStmt = db.prepare(
|
|
126
|
+
"SELECT role, type, status, content, metadata, timestamp FROM session_history WHERE session_id = ? ORDER BY timestamp ASC"
|
|
127
|
+
);
|
|
128
|
+
const history = historyStmt.all(session.id);
|
|
129
|
+
|
|
130
|
+
// Convert each session_history row into a chat message, preserving
|
|
131
|
+
// tool-call structure when present in metadata.
|
|
132
|
+
const messages = [];
|
|
133
|
+
const toolCalls = [];
|
|
134
|
+
|
|
135
|
+
for (const row of history) {
|
|
136
|
+
const meta = parseJsonSafe(row.metadata) || {};
|
|
137
|
+
const content = parseJsonSafe(row.content, row.content);
|
|
138
|
+
|
|
139
|
+
if (row.role === "tool" || row.type === "tool_use" || row.type === "tool_result") {
|
|
140
|
+
// Capture tool calls as a separate stream alongside the chat
|
|
141
|
+
toolCalls.push({
|
|
142
|
+
type: row.type,
|
|
143
|
+
timestamp: row.timestamp,
|
|
144
|
+
content,
|
|
145
|
+
metadata: meta,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (row.role === "user" || row.role === "assistant" || row.role === "system") {
|
|
150
|
+
messages.push({
|
|
151
|
+
role: row.role,
|
|
152
|
+
content,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pull telemetry records associated with this session to enrich.
|
|
158
|
+
const teleRows = telemetry.query({ session_id: session.id, limit: 1000 });
|
|
159
|
+
|
|
160
|
+
const totals = teleRows.reduce(
|
|
161
|
+
(acc, r) => {
|
|
162
|
+
acc.tokens_in += r.input_tokens || 0;
|
|
163
|
+
acc.tokens_out += r.output_tokens || 0;
|
|
164
|
+
acc.latency_ms += r.latency_ms || 0;
|
|
165
|
+
return acc;
|
|
166
|
+
},
|
|
167
|
+
{ tokens_in: 0, tokens_out: 0, latency_ms: 0 }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Pick the modal tier (most-used) and the most-recent model/provider.
|
|
171
|
+
const tier = sessionTier(session.id);
|
|
172
|
+
const last = teleRows[0]; // telemetry.query orders DESC
|
|
173
|
+
const errorRow = teleRows.find((r) => r.error_type);
|
|
174
|
+
const outcome = errorRow ? "error" : "success";
|
|
175
|
+
const complexityAvg =
|
|
176
|
+
teleRows.length > 0
|
|
177
|
+
? Math.round(
|
|
178
|
+
teleRows.reduce((sum, r) => sum + (r.complexity_score || 0), 0) /
|
|
179
|
+
teleRows.length
|
|
180
|
+
)
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
let trajectory = {
|
|
184
|
+
session_id: session.id,
|
|
185
|
+
messages,
|
|
186
|
+
tool_calls: toolCalls,
|
|
187
|
+
outcome,
|
|
188
|
+
tier,
|
|
189
|
+
complexity_score: complexityAvg,
|
|
190
|
+
model_used: last?.model || null,
|
|
191
|
+
provider_used: last?.provider || null,
|
|
192
|
+
tokens_in: totals.tokens_in,
|
|
193
|
+
tokens_out: totals.tokens_out,
|
|
194
|
+
latency_ms: totals.latency_ms,
|
|
195
|
+
started_at: new Date(session.created_at).toISOString(),
|
|
196
|
+
ended_at: new Date(session.updated_at).toISOString(),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (options.anonymize) {
|
|
200
|
+
trajectory = anonymize(trajectory);
|
|
201
|
+
}
|
|
202
|
+
return trajectory;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Stream trajectories to a writable target (a path or a stream).
|
|
207
|
+
*
|
|
208
|
+
* @param {Object} options
|
|
209
|
+
* @param {string|number|Date} [options.since] Window start (ms / Date / "Nd")
|
|
210
|
+
* @param {string} [options.tier] Filter to one tier
|
|
211
|
+
* @param {boolean} [options.anonymize=false] Strip PII / paths / secrets
|
|
212
|
+
* @param {string|stream.Writable} [options.output="-"] File path, "-" for stdout, or a stream
|
|
213
|
+
* @param {function} [options.onProgress] Optional progress callback (count) => void
|
|
214
|
+
* @returns {{ count: number, output: string }}
|
|
215
|
+
*/
|
|
216
|
+
function exportJsonl(options = {}) {
|
|
217
|
+
const since = resolveSince(options.since);
|
|
218
|
+
const sessions = listSessions({ since, tier: options.tier });
|
|
219
|
+
|
|
220
|
+
let stream;
|
|
221
|
+
let outputPath = "-";
|
|
222
|
+
let closeStream = false;
|
|
223
|
+
|
|
224
|
+
if (!options.output || options.output === "-") {
|
|
225
|
+
stream = process.stdout;
|
|
226
|
+
} else if (typeof options.output === "string") {
|
|
227
|
+
outputPath = options.output;
|
|
228
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
229
|
+
stream = fs.createWriteStream(outputPath);
|
|
230
|
+
closeStream = true;
|
|
231
|
+
} else {
|
|
232
|
+
stream = options.output;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let count = 0;
|
|
236
|
+
for (const session of sessions) {
|
|
237
|
+
const trajectory = buildTrajectory(session, options);
|
|
238
|
+
if (!trajectory || trajectory.messages.length === 0) continue;
|
|
239
|
+
stream.write(JSON.stringify(trajectory) + "\n");
|
|
240
|
+
count++;
|
|
241
|
+
if (options.onProgress) options.onProgress(count);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (closeStream) stream.end();
|
|
245
|
+
return { count, output: outputPath };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveSince(value) {
|
|
249
|
+
if (value == null) return null;
|
|
250
|
+
if (value instanceof Date) return value.getTime();
|
|
251
|
+
if (typeof value === "number") return value;
|
|
252
|
+
if (typeof value === "string") {
|
|
253
|
+
const m = value.match(/^(\d+)d$/);
|
|
254
|
+
if (m) return Date.now() - parseInt(m[1], 10) * 24 * 60 * 60 * 1000;
|
|
255
|
+
const parsed = Date.parse(value);
|
|
256
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
exportJsonl,
|
|
263
|
+
buildTrajectory,
|
|
264
|
+
listSessions,
|
|
265
|
+
anonymize,
|
|
266
|
+
};
|