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.
Files changed (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. 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 an
51
- // existing default band (SIMPLE 0-25, MEDIUM 26-50, COMPLEX 51-75) and the
52
- // blend can NEVER reach REASONING (76+). Calibration may later collapse
53
- // bands into degenerate ranges without touching these.
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. Bounded by construction
195
- * to [min(CLASS_VALUES), max(CLASS_VALUES)]REASONING stays unreachable.
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
- const max = Math.max(...classes.map((c) => sims[c] ?? -1));
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 classes) {
203
- const w = Math.exp(((sims[cls] ?? -1) - max) / BLEND_TEMPERATURE);
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(75, score)));
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 score = Math.max(lo, Math.min(hi, blendScore(sims)));
318
- return { score, mode: 'anchor', class: cls, sims, text };
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;