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.
Files changed (65) hide show
  1. package/README.md +21 -10
  2. package/bin/cli.js +18 -1
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/package.json +4 -2
  7. package/public/dashboard.html +665 -0
  8. package/scripts/build-knn-index.js +130 -0
  9. package/scripts/calibrate-thresholds.js +197 -0
  10. package/scripts/compare-policies.js +67 -0
  11. package/scripts/learn-output-ratios.js +162 -0
  12. package/scripts/refresh-pricing.js +122 -0
  13. package/scripts/run-routerarena.js +26 -0
  14. package/scripts/sample-regret.js +84 -0
  15. package/scripts/train-risk-classifier.js +191 -0
  16. package/src/api/files-router.js +6 -6
  17. package/src/api/middleware/budget-enforcer.js +60 -0
  18. package/src/api/middleware/budget.js +19 -1
  19. package/src/api/middleware/load-shedding.js +17 -0
  20. package/src/api/middleware/tenant.js +21 -0
  21. package/src/api/openai-router.js +1 -1
  22. package/src/api/router.js +204 -87
  23. package/src/budget/hierarchical-budget.js +159 -0
  24. package/src/cache/semantic.js +28 -2
  25. package/src/clients/databricks.js +68 -10
  26. package/src/clients/openai-format.js +31 -5
  27. package/src/config/index.js +246 -43
  28. package/src/context/toon.js +5 -4
  29. package/src/dashboard/api.js +170 -0
  30. package/src/dashboard/router.js +13 -0
  31. package/src/headroom/client.js +3 -109
  32. package/src/headroom/index.js +0 -14
  33. package/src/memory/search.js +0 -50
  34. package/src/orchestrator/index.js +106 -11
  35. package/src/orchestrator/preflight.js +188 -0
  36. package/src/prompts/system.js +34 -6
  37. package/src/routing/bandit.js +246 -0
  38. package/src/routing/cascade.js +106 -0
  39. package/src/routing/complexity-analyzer.js +7 -15
  40. package/src/routing/confidence-scorer.js +121 -0
  41. package/src/routing/context-validator.js +71 -0
  42. package/src/routing/cost-optimizer.js +5 -2
  43. package/src/routing/deadline.js +52 -0
  44. package/src/routing/drift-monitor.js +113 -0
  45. package/src/routing/embedding-cache.js +77 -0
  46. package/src/routing/index.js +374 -4
  47. package/src/routing/interaction.js +183 -0
  48. package/src/routing/knn-router.js +206 -0
  49. package/src/routing/latency-tracker.js +113 -71
  50. package/src/routing/model-tiers.js +156 -6
  51. package/src/routing/output-ratios.js +57 -0
  52. package/src/routing/regret-estimator.js +91 -0
  53. package/src/routing/reward-pipeline.js +62 -0
  54. package/src/routing/risk-analyzer.js +194 -0
  55. package/src/routing/risk-classifier.js +130 -0
  56. package/src/routing/shadow-mode.js +77 -0
  57. package/src/routing/telemetry.js +7 -0
  58. package/src/routing/tenant-policy.js +96 -0
  59. package/src/routing/tokenizer.js +162 -0
  60. package/src/server.js +12 -0
  61. package/src/stores/file-store.js +42 -7
  62. package/src/tools/smart-selection.js +11 -2
  63. package/src/training/trajectory-compressor.js +266 -0
  64. package/src/usage/aggregator.js +206 -0
  65. 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);
@@ -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 storeFile(buffer, { filename, purpose, mimeType }) {
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
- fs.writeFileSync(storagePath, buffer);
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 fs.readFileSync(entry.storage_path);
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 { fs.unlinkSync(entry.storage_path); } catch {}
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
- let selectedTools = tools.filter(tool => relevantToolNames.includes(tool.name));
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
+ };