lynkr 9.9.0 → 9.10.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/README.md +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -47,14 +47,15 @@ const ANCHORS_PATHS = [
|
|
|
47
47
|
];
|
|
48
48
|
const VECTORS_CACHE_PATH = path.join(__dirname, '../../data/difficulty-anchors.vectors.json');
|
|
49
49
|
|
|
50
|
-
// Class → representative score. Chosen so each class lands inside
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
50
|
+
// Class → representative score. Chosen so each class lands inside a tier
|
|
51
|
+
// band and routing boundaries are tunable via tier edges (model-tiers.js).
|
|
52
|
+
// Config B (local → GLM → Claude): substantive=MEDIUM/ollama,
|
|
53
|
+
// heavyweight=COMPLEX/GLM, frontier=REASONING/Claude.
|
|
54
54
|
const CLASS_VALUES = {
|
|
55
55
|
trivial: 10,
|
|
56
56
|
substantive: 45,
|
|
57
57
|
heavyweight: 68,
|
|
58
|
+
frontier: 85,
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
// Softmax temperature over cosine sims. Real inter-class sim gaps on
|
|
@@ -77,8 +78,16 @@ const CLASS_BANDS = {
|
|
|
77
78
|
trivial: [0, 25],
|
|
78
79
|
substantive: [26, 50],
|
|
79
80
|
heavyweight: [51, 75],
|
|
81
|
+
frontier: [76, 100],
|
|
80
82
|
};
|
|
81
83
|
|
|
84
|
+
// Frontier class (REASONING tier) requires minimum similarity — a weak
|
|
85
|
+
// topical match can't jump to the expensive tier. Tuned on the validation
|
|
86
|
+
// set (scripts/validate-intent-anchors.js). Below this floor, frontier is
|
|
87
|
+
// excluded from the blend entirely and scoring behaves like the 3-class
|
|
88
|
+
// baseline. Hardcoded constant (no env var per user directive).
|
|
89
|
+
const FRONTIER_MIN_SIM = 0.50;
|
|
90
|
+
|
|
82
91
|
function intentScoreMode() {
|
|
83
92
|
const m = (process.env.LYNKR_INTENT_SCORE_MODE || 'anchor').toLowerCase();
|
|
84
93
|
return m === 'legacy' ? 'legacy' : 'anchor';
|
|
@@ -191,21 +200,32 @@ function classify(embedding, centroids) {
|
|
|
191
200
|
}
|
|
192
201
|
|
|
193
202
|
/**
|
|
194
|
-
* Softmax-blend class sims into a continuous score.
|
|
195
|
-
*
|
|
203
|
+
* Softmax-blend class sims into a continuous score. Frontier class requires
|
|
204
|
+
* minimum similarity (FRONTIER_MIN_SIM) to participate — below the floor,
|
|
205
|
+
* it's excluded from the blend and scoring behaves like the 3-class baseline.
|
|
206
|
+
* Lexical fallback path stays clamped ≤75 (see _lexicalCleanScore).
|
|
196
207
|
*/
|
|
197
208
|
function blendScore(sims) {
|
|
198
209
|
const classes = Object.keys(CLASS_VALUES);
|
|
199
|
-
|
|
210
|
+
// Frontier similarity floor: a weak topical match can't jump tiers.
|
|
211
|
+
const frontierSim = sims.frontier ?? -1;
|
|
212
|
+
const activeSims = { ...sims };
|
|
213
|
+
if (frontierSim < FRONTIER_MIN_SIM) {
|
|
214
|
+
delete activeSims.frontier; // excluded from blend
|
|
215
|
+
}
|
|
216
|
+
const activeClasses = Object.keys(CLASS_VALUES).filter(c => c in activeSims);
|
|
217
|
+
if (activeClasses.length === 0) return CLASS_VALUES.substantive; // degenerate
|
|
218
|
+
|
|
219
|
+
const max = Math.max(...activeClasses.map((c) => activeSims[c] ?? -1));
|
|
200
220
|
let totalW = 0;
|
|
201
221
|
let total = 0;
|
|
202
|
-
for (const cls of
|
|
203
|
-
const w = Math.exp(((
|
|
222
|
+
for (const cls of activeClasses) {
|
|
223
|
+
const w = Math.exp(((activeSims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
|
|
204
224
|
totalW += w;
|
|
205
225
|
total += w * CLASS_VALUES[cls];
|
|
206
226
|
}
|
|
207
227
|
const score = totalW > 0 ? total / totalW : CLASS_VALUES.substantive;
|
|
208
|
-
return Math.round(Math.max(0, Math.min(
|
|
228
|
+
return Math.round(Math.max(0, Math.min(100, score)));
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
// --- default centroids (lazy singleton, disk-cached) ------------------------
|
|
@@ -294,6 +314,58 @@ function _lexicalCleanScore(text) {
|
|
|
294
314
|
* @returns {Promise<{score:number, mode:'anchor'|'lexical', class?:string, sims?:object, text:string}|null>}
|
|
295
315
|
* null → caller keeps its legacy score (legacy mode, or nothing to score)
|
|
296
316
|
*/
|
|
317
|
+
// Reconcile anchor's implied tier with the LLM classifier's tier.
|
|
318
|
+
// - Agreement → keep anchor score as-is.
|
|
319
|
+
// - Classifier lower than anchor → trust classifier (catches embedding
|
|
320
|
+
// false-positives like "list exports" scoring REASONING). Position
|
|
321
|
+
// score at midpoint of classifier's target band.
|
|
322
|
+
// - Classifier higher than anchor → safety-gate: require confidence≥0.8
|
|
323
|
+
// before trusting an escalation to a more expensive tier. Below that,
|
|
324
|
+
// keep the cheaper anchor decision.
|
|
325
|
+
function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
326
|
+
if (!classifierResult) return { score: anchorScore, reconciled: false };
|
|
327
|
+
if (classifierResult.confidence < 0.6) return { score: anchorScore, reconciled: false };
|
|
328
|
+
|
|
329
|
+
// Anchor class → implied tier (matches model-tiers.js band definitions).
|
|
330
|
+
const anchorTier = anchorScore <= 19 ? 'SIMPLE'
|
|
331
|
+
: anchorScore <= 50 ? 'MEDIUM'
|
|
332
|
+
: anchorScore <= 75 ? 'COMPLEX'
|
|
333
|
+
: 'REASONING';
|
|
334
|
+
const classifierTier = classifierResult.tier;
|
|
335
|
+
|
|
336
|
+
if (anchorTier === classifierTier) return { score: anchorScore, reconciled: false };
|
|
337
|
+
|
|
338
|
+
const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
339
|
+
const anchorIdx = TIER_ORDER.indexOf(anchorTier);
|
|
340
|
+
const classifierIdx = TIER_ORDER.indexOf(classifierTier);
|
|
341
|
+
|
|
342
|
+
// Midpoints of each tier band (from model-tiers.js defaults):
|
|
343
|
+
// SIMPLE 0-19 → 10, MEDIUM 20-50 → 35, COMPLEX 51-75 → 63, REASONING 76-100 → 88
|
|
344
|
+
const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
|
|
345
|
+
|
|
346
|
+
if (classifierIdx < anchorIdx) {
|
|
347
|
+
// Classifier says LOWER tier — trust it. Fixes over-routing.
|
|
348
|
+
return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
|
|
349
|
+
}
|
|
350
|
+
// Classifier says HIGHER tier — gate on confidence, and cap the jump at
|
|
351
|
+
// ONE band above the anchor. The classifier is a tiebreaker, not an
|
|
352
|
+
// oracle: qwen2.5:3b's confidence is degenerate (92% of eval answers say
|
|
353
|
+
// 0.9-1.0, incl. every wrong one), so an unbounded jump let a 3B verdict
|
|
354
|
+
// catapult "Who kills him ?" from anchor 25 to 88 → REASONING →
|
|
355
|
+
// subscription passthrough (live incident 2026-07-21). Escalating past
|
|
356
|
+
// the adjacent band now takes consecutive turns that keep re-scoring
|
|
357
|
+
// higher, which is exactly the persistence a genuinely hard conversation
|
|
358
|
+
// exhibits.
|
|
359
|
+
if (classifierResult.confidence >= 0.8) {
|
|
360
|
+
const cappedIdx = Math.min(classifierIdx, anchorIdx + 1);
|
|
361
|
+
return {
|
|
362
|
+
score: TIER_MIDPOINT[TIER_ORDER[cappedIdx]],
|
|
363
|
+
reconciled: cappedIdx < classifierIdx ? 'up_capped' : 'up',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return { score: anchorScore, reconciled: 'up_gated' };
|
|
367
|
+
}
|
|
368
|
+
|
|
297
369
|
async function scoreIntent(payload, opts = {}) {
|
|
298
370
|
const mode = opts.mode ?? intentScoreMode();
|
|
299
371
|
if (mode === 'legacy') return null;
|
|
@@ -314,8 +386,52 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
314
386
|
if (Array.isArray(embedding) && embedding.length > 0) {
|
|
315
387
|
const { cls, sims } = classify(embedding, centroids);
|
|
316
388
|
const [lo, hi] = CLASS_BANDS[cls];
|
|
317
|
-
const
|
|
318
|
-
|
|
389
|
+
const anchorScore = Math.max(lo, Math.min(hi, blendScore(sims)));
|
|
390
|
+
|
|
391
|
+
// LLM classifier — second opinion. Skipped in tests (opts.skipClassifier)
|
|
392
|
+
// to keep unit tests hermetic. Runs live otherwise.
|
|
393
|
+
let classifierResult = null;
|
|
394
|
+
if (!opts.skipClassifier) {
|
|
395
|
+
try {
|
|
396
|
+
const { classifyDifficulty } = require('./difficulty-classifier');
|
|
397
|
+
classifierResult = await classifyDifficulty(text, {
|
|
398
|
+
forceMatched: opts.forceMatched,
|
|
399
|
+
riskLevel: opts.riskLevel,
|
|
400
|
+
// Condensed prior turns, threaded by the router's window loop.
|
|
401
|
+
// Without it a short follow-up ("Who kills him ?") is
|
|
402
|
+
// unclassifiable in isolation.
|
|
403
|
+
context: typeof payload?._conversationContext === 'string'
|
|
404
|
+
? payload._conversationContext
|
|
405
|
+
: null,
|
|
406
|
+
});
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult);
|
|
413
|
+
if (reconciled) {
|
|
414
|
+
logger.debug({
|
|
415
|
+
text: text.slice(0, 80),
|
|
416
|
+
anchorScore,
|
|
417
|
+
anchorClass: cls,
|
|
418
|
+
classifierTier: classifierResult?.tier,
|
|
419
|
+
classifierConfidence: classifierResult?.confidence,
|
|
420
|
+
reconciled,
|
|
421
|
+
finalScore: score,
|
|
422
|
+
}, '[IntentScore] classifier reconciled anchor score');
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
score,
|
|
426
|
+
mode: reconciled ? 'anchor+classifier' : 'anchor',
|
|
427
|
+
class: cls,
|
|
428
|
+
sims,
|
|
429
|
+
text,
|
|
430
|
+
anchorScore,
|
|
431
|
+
classifierTier: classifierResult?.tier ?? null,
|
|
432
|
+
classifierConfidence: classifierResult?.confidence ?? null,
|
|
433
|
+
reconciled,
|
|
434
|
+
};
|
|
319
435
|
}
|
|
320
436
|
}
|
|
321
437
|
} catch (err) {
|
|
@@ -327,6 +443,8 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
327
443
|
|
|
328
444
|
module.exports = {
|
|
329
445
|
CLASS_VALUES,
|
|
446
|
+
CLASS_BANDS,
|
|
447
|
+
FRONTIER_MIN_SIM,
|
|
330
448
|
intentScoreMode,
|
|
331
449
|
extractCleanUserText,
|
|
332
450
|
cosine,
|
|
@@ -336,4 +454,6 @@ module.exports = {
|
|
|
336
454
|
scoreIntent,
|
|
337
455
|
// exposed for the replay script
|
|
338
456
|
getDefaultCentroids,
|
|
457
|
+
// exposed for tests
|
|
458
|
+
_reconcile,
|
|
339
459
|
};
|
|
@@ -114,12 +114,18 @@ function getPin(sessionId) {
|
|
|
114
114
|
* Accepts a routing decision plus session-scoped stats used by the re-pin
|
|
115
115
|
* heuristics.
|
|
116
116
|
*
|
|
117
|
+
* `hasToolHistory` is sticky-true: once set to true it stays true for the
|
|
118
|
+
* pin's lifetime — a follow-up refresh that didn't carry tool blocks (e.g.
|
|
119
|
+
* a plain-text turn between tool exchanges) must not clear the flag, or the
|
|
120
|
+
* Signal-2 side-channel check would flip open again.
|
|
121
|
+
*
|
|
117
122
|
* @param {string} sessionId
|
|
118
123
|
* @param {{provider:string, model?:string|null, tier?:string|null}} decision
|
|
119
|
-
* @param {{messageCount?:number|null, promptTokensEst?:number|null}} [stats]
|
|
124
|
+
* @param {{messageCount?:number|null, promptTokensEst?:number|null, hasToolHistory?:boolean}} [stats]
|
|
120
125
|
*/
|
|
121
126
|
function setPin(sessionId, decision, stats = {}) {
|
|
122
127
|
if (!sessionId || !decision?.provider) return;
|
|
128
|
+
const prev = pins.get(sessionId);
|
|
123
129
|
const pin = {
|
|
124
130
|
provider: decision.provider,
|
|
125
131
|
model: decision.model ?? null,
|
|
@@ -127,6 +133,7 @@ function setPin(sessionId, decision, stats = {}) {
|
|
|
127
133
|
score: typeof decision.score === 'number' ? decision.score : null,
|
|
128
134
|
messageCount: stats.messageCount ?? null,
|
|
129
135
|
promptTokensEst: stats.promptTokensEst ?? null,
|
|
136
|
+
hasToolHistory: !!(stats.hasToolHistory || prev?.hasToolHistory),
|
|
130
137
|
ts: Date.now(),
|
|
131
138
|
};
|
|
132
139
|
// Refresh insertion order so active sessions aren't evicted.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-Channel Detector — first-user-message fingerprint drift
|
|
3
|
+
*
|
|
4
|
+
* Complements the pin-based side-channel signals in router.js:
|
|
5
|
+
* 1. Message-count regression → needs pin
|
|
6
|
+
* 2. Tool-history mismatch → needs pin with hasToolHistory
|
|
7
|
+
* 3. First-message fingerprint → THIS module — works before any pin exists
|
|
8
|
+
*
|
|
9
|
+
* Purpose: on a session's first request, cache a fingerprint of messages[0].
|
|
10
|
+
* On later requests within the TTL, if messages[0] no longer matches, the
|
|
11
|
+
* payload has been replayed by a background utility caller (Claude Code
|
|
12
|
+
* wraps the original user text in `<session>...</session>` for title-gen,
|
|
13
|
+
* substitutes recap prompts, etc.).
|
|
14
|
+
*
|
|
15
|
+
* Trade-off: if a background call fires BEFORE the first real user turn
|
|
16
|
+
* lands, its wrapper prompt becomes the anchor. That's rare in practice —
|
|
17
|
+
* observed order in real traffic is user turn first, background call after.
|
|
18
|
+
* If it does happen, the real user's next request will be mislabelled once,
|
|
19
|
+
* then the anchor will "settle" once ttl expires.
|
|
20
|
+
*
|
|
21
|
+
* @module routing/side-channel-detector
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const logger = require('../logger');
|
|
26
|
+
|
|
27
|
+
const TTL_MS = 30 * 60 * 1000; // 30 min — matches an interactive Claude Code session
|
|
28
|
+
const MAX_ENTRIES = 2000; // LRU cap (same order of magnitude as session-affinity)
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, {hash:string, ts:number}>} */
|
|
31
|
+
const anchors = new Map();
|
|
32
|
+
|
|
33
|
+
function _evictIfNeeded() {
|
|
34
|
+
if (anchors.size <= MAX_ENTRIES) return;
|
|
35
|
+
const oldest = anchors.keys().next().value;
|
|
36
|
+
if (oldest !== undefined) anchors.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _firstUserText(messages) {
|
|
40
|
+
if (!Array.isArray(messages)) return null;
|
|
41
|
+
for (const m of messages) {
|
|
42
|
+
if (m?.role !== 'user') continue;
|
|
43
|
+
if (typeof m.content === 'string') return m.content;
|
|
44
|
+
if (Array.isArray(m.content)) {
|
|
45
|
+
const t = m.content.filter(b => b?.type === 'text').map(b => b.text || '').join('');
|
|
46
|
+
if (t) return t;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _hash(str) {
|
|
53
|
+
return crypto.createHash('sha1').update(str).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check whether the payload's first user message drifts from the cached
|
|
58
|
+
* anchor for this session. Returns true only when we have an anchor AND
|
|
59
|
+
* it disagrees; returns false when no anchor exists yet (bootstrap case)
|
|
60
|
+
* and stores this payload's hash as the new anchor.
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null} sessionId
|
|
63
|
+
* @param {Array} messages
|
|
64
|
+
* @returns {boolean} true if drift detected (caller should treat as side channel)
|
|
65
|
+
*/
|
|
66
|
+
function check(sessionId, messages) {
|
|
67
|
+
if (!sessionId) return false;
|
|
68
|
+
const text = _firstUserText(messages);
|
|
69
|
+
if (!text) return false;
|
|
70
|
+
const hash = _hash(text);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
|
|
73
|
+
const cached = anchors.get(sessionId);
|
|
74
|
+
if (cached) {
|
|
75
|
+
// Refresh insertion order for LRU
|
|
76
|
+
anchors.delete(sessionId);
|
|
77
|
+
if (now - cached.ts > TTL_MS) {
|
|
78
|
+
// Expired — reset the anchor, no drift claim.
|
|
79
|
+
anchors.set(sessionId, { hash, ts: now });
|
|
80
|
+
_evictIfNeeded();
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
anchors.set(sessionId, cached);
|
|
84
|
+
if (cached.hash !== hash) {
|
|
85
|
+
logger.debug({ sessionId, cachedHash: cached.hash, newHash: hash },
|
|
86
|
+
'[SideChannel] First-message fingerprint drift');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Bootstrap — first sighting of this session, store and pass.
|
|
93
|
+
anchors.set(sessionId, { hash, ts: now });
|
|
94
|
+
_evictIfNeeded();
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Test/maintenance helper — clear the in-memory anchors. */
|
|
99
|
+
function _clear() {
|
|
100
|
+
anchors.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { check, _clear };
|
package/src/server.js
CHANGED
|
@@ -18,52 +18,13 @@ const { getCircuitBreakerRegistry } = require("./clients/circuit-breaker");
|
|
|
18
18
|
const metrics = require("./metrics");
|
|
19
19
|
const logger = require("./logger");
|
|
20
20
|
const { initialiseMcp } = require("./mcp");
|
|
21
|
-
const { registerStubTools } = require("./tools/stubs");
|
|
22
|
-
const { registerWorkspaceTools } = require("./tools/workspace");
|
|
23
|
-
const { registerExecutionTools } = require("./tools/execution");
|
|
24
|
-
const { registerWebTools } = require("./tools/web");
|
|
25
|
-
const { registerIndexerTools } = require("./tools/indexer");
|
|
26
|
-
const { registerEditTools } = require("./tools/edits");
|
|
27
|
-
const { registerGitTools } = require("./tools/git");
|
|
28
|
-
const { registerTaskTools } = require("./tools/tasks");
|
|
29
|
-
const { registerTestTools } = require("./tools/tests");
|
|
30
|
-
const { registerMcpTools } = require("./tools/mcp");
|
|
31
|
-
const { registerAgentTaskTool } = require("./tools/agent-task");
|
|
32
|
-
const { registerDecomposeTool } = require("./tools/decompose");
|
|
33
21
|
const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
|
|
34
22
|
const { initializeHeadroom, shutdownHeadroom, getHeadroomManager } = require("./headroom");
|
|
35
23
|
const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
|
|
36
|
-
const lazyLoader = require("./tools/lazy-loader");
|
|
37
|
-
const { setLazyLoader } = require("./tools");
|
|
38
24
|
const { waitForOllama } = require("./clients/ollama-startup");
|
|
39
25
|
|
|
40
26
|
initialiseMcp();
|
|
41
27
|
|
|
42
|
-
setLazyLoader(lazyLoader);
|
|
43
|
-
|
|
44
|
-
const LAZY_TOOLS_ENABLED = process.env.LAZY_TOOLS_ENABLED !== "false";
|
|
45
|
-
|
|
46
|
-
if (LAZY_TOOLS_ENABLED) {
|
|
47
|
-
// Only load core tools at startup (stubs, workspace, execution)
|
|
48
|
-
lazyLoader.loadCoreTools();
|
|
49
|
-
logger.info({ mode: "lazy" }, "Lazy tool loading enabled - other tools will load on demand");
|
|
50
|
-
} else {
|
|
51
|
-
// Backwards compatibility: load all tools at startup
|
|
52
|
-
registerStubTools();
|
|
53
|
-
registerWorkspaceTools();
|
|
54
|
-
registerExecutionTools();
|
|
55
|
-
registerWebTools();
|
|
56
|
-
registerIndexerTools();
|
|
57
|
-
registerEditTools();
|
|
58
|
-
registerGitTools();
|
|
59
|
-
registerTaskTools();
|
|
60
|
-
registerTestTools();
|
|
61
|
-
registerMcpTools();
|
|
62
|
-
registerAgentTaskTool();
|
|
63
|
-
registerDecomposeTool();
|
|
64
|
-
logger.info({ mode: "eager" }, "All tools loaded at startup");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
28
|
function createApp() {
|
|
68
29
|
const app = express();
|
|
69
30
|
const path = require('path');
|
|
@@ -171,13 +132,6 @@ function createApp() {
|
|
|
171
132
|
res.json({ enabled: true, ...cache.getStats() });
|
|
172
133
|
});
|
|
173
134
|
|
|
174
|
-
app.get("/metrics/lazy-tools", (req, res) => {
|
|
175
|
-
res.json({
|
|
176
|
-
enabled: LAZY_TOOLS_ENABLED,
|
|
177
|
-
...lazyLoader.getLoaderStats(),
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
135
|
app.use(router);
|
|
182
136
|
|
|
183
137
|
app.use('/dashboard', require('./dashboard/router'));
|
|
@@ -246,6 +200,26 @@ async function start() {
|
|
|
246
200
|
console.log(`Claude→Databricks proxy listening on http://localhost:${config.port}`);
|
|
247
201
|
});
|
|
248
202
|
|
|
203
|
+
// Classifier bootstrap check — non-blocking, log-only.
|
|
204
|
+
// Detects ollama + confirms the classifier model is pulled. Never auto-
|
|
205
|
+
// installs (that's `lynkr init`'s job); warns and lets scoring fall back
|
|
206
|
+
// to anchor-only if either is missing.
|
|
207
|
+
(async () => {
|
|
208
|
+
try {
|
|
209
|
+
const { ensureClassifierReady } = require('./routing/classifier-setup');
|
|
210
|
+
const result = await ensureClassifierReady({
|
|
211
|
+
mode: 'boot',
|
|
212
|
+
log: (m) => logger.info(m),
|
|
213
|
+
warn: (m) => logger.warn(m),
|
|
214
|
+
});
|
|
215
|
+
if (result.ready) {
|
|
216
|
+
logger.info({ classifier: 'ready', warmed: result.warmed }, '[classifier-setup] Difficulty classifier ready');
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
logger.debug({ err: err.message }, '[classifier-setup] bootstrap check failed (classifier will fall back)');
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
|
|
249
223
|
// Start session cleanup manager. It also drives routing-side maintenance
|
|
250
224
|
// (telemetry retention + session-pin TTL) via its runCleanup tick — see
|
|
251
225
|
// src/sessions/cleanup.js.
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
const fs = require("fs");
|
|
2
|
-
const path = require("path");
|
|
3
|
-
const logger = require("../logger");
|
|
4
|
-
|
|
5
|
-
class ContextManager {
|
|
6
|
-
constructor() {
|
|
7
|
-
this.transcriptsDir = path.join(process.cwd(), "data", "agent-transcripts");
|
|
8
|
-
this.ensureTranscriptsDir();
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
ensureTranscriptsDir() {
|
|
12
|
-
if (!fs.existsSync(this.transcriptsDir)) {
|
|
13
|
-
fs.mkdirSync(this.transcriptsDir, { recursive: true });
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Create fresh context for subagent
|
|
19
|
-
* @param {Object} agentDef - Agent definition
|
|
20
|
-
* @param {string} taskPrompt - Task from main agent
|
|
21
|
-
* @param {Object} mainContext - Minimal context from main agent
|
|
22
|
-
* @returns {Object} - Fresh context for subagent
|
|
23
|
-
*/
|
|
24
|
-
createSubagentContext(agentDef, taskPrompt, mainContext = {}) {
|
|
25
|
-
const agentId = this.generateAgentId();
|
|
26
|
-
const transcriptPath = path.join(this.transcriptsDir, `agent-${agentId}.jsonl`);
|
|
27
|
-
|
|
28
|
-
// Initialize transcript file
|
|
29
|
-
fs.writeFileSync(transcriptPath, "");
|
|
30
|
-
|
|
31
|
-
// Build minimal context (NOT full main agent history)
|
|
32
|
-
const messages = [];
|
|
33
|
-
|
|
34
|
-
// System prompt from agent definition
|
|
35
|
-
messages.push({
|
|
36
|
-
role: "system",
|
|
37
|
-
content: agentDef.systemPrompt
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Optional: Add minimal context from main agent
|
|
41
|
-
if (mainContext.relevant_context) {
|
|
42
|
-
messages.push({
|
|
43
|
-
role: "system",
|
|
44
|
-
content: `Context from main agent:\n${mainContext.relevant_context}`
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Task prompt
|
|
49
|
-
messages.push({
|
|
50
|
-
role: "user",
|
|
51
|
-
content: taskPrompt
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const context = {
|
|
55
|
-
agentId,
|
|
56
|
-
agentName: agentDef.name,
|
|
57
|
-
transcriptPath,
|
|
58
|
-
messages,
|
|
59
|
-
steps: 0,
|
|
60
|
-
maxSteps: agentDef.maxSteps,
|
|
61
|
-
model: agentDef.model,
|
|
62
|
-
allowedTools: agentDef.allowedTools,
|
|
63
|
-
startTime: Date.now(),
|
|
64
|
-
|
|
65
|
-
// Original task prompt — consumed by the Reflector to infer task type.
|
|
66
|
-
taskPrompt,
|
|
67
|
-
|
|
68
|
-
// In-memory transcript mirror of the JSONL file — consumed by the
|
|
69
|
-
// Reflector for tool-usage / error-recovery analysis. The JSONL file
|
|
70
|
-
// remains the durable record; this array is the hot-path copy.
|
|
71
|
-
transcript: [],
|
|
72
|
-
|
|
73
|
-
// Token tracking
|
|
74
|
-
inputTokens: 0,
|
|
75
|
-
outputTokens: 0,
|
|
76
|
-
|
|
77
|
-
// State
|
|
78
|
-
terminated: false,
|
|
79
|
-
result: null
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
this.writeTranscriptEntry(transcriptPath, {
|
|
83
|
-
type: "agent_start",
|
|
84
|
-
agentId,
|
|
85
|
-
agentName: agentDef.name,
|
|
86
|
-
taskPrompt,
|
|
87
|
-
timestamp: Date.now()
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
logger.info({ agentId, agentName: agentDef.name }, "Created fresh subagent context");
|
|
91
|
-
|
|
92
|
-
return context;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Add message to subagent context
|
|
97
|
-
*/
|
|
98
|
-
addMessage(context, message) {
|
|
99
|
-
context.messages.push(message);
|
|
100
|
-
|
|
101
|
-
this.writeTranscriptEntry(context.transcriptPath, {
|
|
102
|
-
type: "message",
|
|
103
|
-
agentId: context.agentId,
|
|
104
|
-
message,
|
|
105
|
-
timestamp: Date.now()
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Record tool execution in transcript
|
|
111
|
-
*/
|
|
112
|
-
recordToolCall(context, toolName, input, output, error = null) {
|
|
113
|
-
const entry = {
|
|
114
|
-
type: "tool_call",
|
|
115
|
-
agentId: context.agentId,
|
|
116
|
-
step: context.steps,
|
|
117
|
-
toolName,
|
|
118
|
-
input,
|
|
119
|
-
output: error ? null : output,
|
|
120
|
-
error: error ? error.message : null,
|
|
121
|
-
timestamp: Date.now()
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// Keep the in-memory transcript in sync so the Reflector (which reads
|
|
125
|
-
// context.transcript) sees tool calls without re-parsing the JSONL file.
|
|
126
|
-
if (Array.isArray(context.transcript)) {
|
|
127
|
-
context.transcript.push(entry);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
this.writeTranscriptEntry(context.transcriptPath, entry);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Complete subagent execution
|
|
135
|
-
*/
|
|
136
|
-
completeExecution(context, result) {
|
|
137
|
-
context.terminated = true;
|
|
138
|
-
context.result = result;
|
|
139
|
-
|
|
140
|
-
this.writeTranscriptEntry(context.transcriptPath, {
|
|
141
|
-
type: "agent_complete",
|
|
142
|
-
agentId: context.agentId,
|
|
143
|
-
result,
|
|
144
|
-
stats: {
|
|
145
|
-
steps: context.steps,
|
|
146
|
-
durationMs: Date.now() - context.startTime,
|
|
147
|
-
inputTokens: context.inputTokens,
|
|
148
|
-
outputTokens: context.outputTokens
|
|
149
|
-
},
|
|
150
|
-
timestamp: Date.now()
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
logger.info({
|
|
154
|
-
agentId: context.agentId,
|
|
155
|
-
steps: context.steps,
|
|
156
|
-
durationMs: Date.now() - context.startTime
|
|
157
|
-
}, "Subagent execution completed");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Fail subagent execution
|
|
162
|
-
*/
|
|
163
|
-
failExecution(context, error) {
|
|
164
|
-
context.terminated = true;
|
|
165
|
-
|
|
166
|
-
this.writeTranscriptEntry(context.transcriptPath, {
|
|
167
|
-
type: "agent_failed",
|
|
168
|
-
agentId: context.agentId,
|
|
169
|
-
error: error.message,
|
|
170
|
-
stats: {
|
|
171
|
-
steps: context.steps,
|
|
172
|
-
durationMs: Date.now() - context.startTime
|
|
173
|
-
},
|
|
174
|
-
timestamp: Date.now()
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
logger.error({
|
|
178
|
-
agentId: context.agentId,
|
|
179
|
-
error: error.message
|
|
180
|
-
}, "Subagent execution failed");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Write entry to transcript file (JSONL format)
|
|
185
|
-
*/
|
|
186
|
-
writeTranscriptEntry(transcriptPath, entry) {
|
|
187
|
-
try {
|
|
188
|
-
fs.appendFileSync(transcriptPath, JSON.stringify(entry) + "\n");
|
|
189
|
-
} catch (error) {
|
|
190
|
-
logger.warn({ error: error.message }, "Failed to write transcript entry");
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Read transcript for debugging
|
|
196
|
-
*/
|
|
197
|
-
readTranscript(agentId) {
|
|
198
|
-
const transcriptPath = path.join(this.transcriptsDir, `agent-${agentId}.jsonl`);
|
|
199
|
-
|
|
200
|
-
if (!fs.existsSync(transcriptPath)) {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(l => l.trim());
|
|
205
|
-
return lines.map(line => JSON.parse(line));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Generate unique agent ID
|
|
210
|
-
*/
|
|
211
|
-
generateAgentId() {
|
|
212
|
-
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Clean old transcripts (older than 7 days)
|
|
217
|
-
*/
|
|
218
|
-
cleanOldTranscripts() {
|
|
219
|
-
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
220
|
-
const now = Date.now();
|
|
221
|
-
|
|
222
|
-
const files = fs.readdirSync(this.transcriptsDir);
|
|
223
|
-
|
|
224
|
-
for (const file of files) {
|
|
225
|
-
const filePath = path.join(this.transcriptsDir, file);
|
|
226
|
-
const stats = fs.statSync(filePath);
|
|
227
|
-
|
|
228
|
-
if (now - stats.mtimeMs > maxAge) {
|
|
229
|
-
fs.unlinkSync(filePath);
|
|
230
|
-
logger.debug({ file }, "Cleaned old transcript");
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
module.exports = ContextManager;
|