@juspay/neurolink 12.18.0 → 12.19.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 (77) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +277 -75
  3. package/dist/browser/neurolink.min.js +396 -396
  4. package/dist/cli/commands/setup.js +2 -1
  5. package/dist/cli/factories/commandFactory.d.ts +22 -2
  6. package/dist/cli/factories/commandFactory.js +14 -2
  7. package/dist/cli/loop/optionsSchema.js +4 -0
  8. package/dist/cli/utils/classifierRouterFlags.js +39 -1
  9. package/dist/constants/enums.d.ts +13 -0
  10. package/dist/constants/enums.js +14 -0
  11. package/dist/context/budgetChecker.d.ts +3 -1
  12. package/dist/context/budgetChecker.js +20 -4
  13. package/dist/context/contextCompactor.d.ts +43 -3
  14. package/dist/context/contextCompactor.js +157 -41
  15. package/dist/context/contextDecision.d.ts +56 -0
  16. package/dist/context/contextDecision.js +234 -0
  17. package/dist/core/baseProvider.d.ts +27 -1
  18. package/dist/core/baseProvider.js +39 -0
  19. package/dist/core/toolRouting.d.ts +8 -0
  20. package/dist/core/toolRouting.js +52 -1
  21. package/dist/core/toolRoutingDecision.d.ts +35 -0
  22. package/dist/core/toolRoutingDecision.js +194 -0
  23. package/dist/factories/providerDescriptors.d.ts +15 -0
  24. package/dist/factories/providerDescriptors.js +57 -1
  25. package/dist/factories/providerRegistry.js +9 -1
  26. package/dist/index.d.ts +57 -1
  27. package/dist/index.js +60 -1
  28. package/dist/models/manifestRegistry.js +2 -0
  29. package/dist/models/manifests/typesafe.d.ts +16 -0
  30. package/dist/models/manifests/typesafe.js +42 -0
  31. package/dist/neurolink.d.ts +57 -1
  32. package/dist/neurolink.js +254 -10
  33. package/dist/observability/exporters/laminarExporter.js +3 -0
  34. package/dist/observability/exporters/posthogExporter.js +3 -0
  35. package/dist/observability/utils/spanSerializer.js +4 -0
  36. package/dist/providers/catalog/schema.d.ts +1 -1
  37. package/dist/providers/typesafe.d.ts +112 -0
  38. package/dist/providers/typesafe.js +654 -0
  39. package/dist/proxy/proxyHistoryTruncation.js +15 -10
  40. package/dist/rag/pipeline/RAGPipeline.d.ts +3 -1
  41. package/dist/rag/pipeline/RAGPipeline.js +65 -4
  42. package/dist/rag/retrieval/searchDecision.d.ts +27 -0
  43. package/dist/rag/retrieval/searchDecision.js +124 -0
  44. package/dist/routing/classifierRouter.d.ts +74 -2
  45. package/dist/routing/classifierRouter.js +232 -32
  46. package/dist/routing/classifierStrategies.d.ts +33 -1
  47. package/dist/routing/classifierStrategies.js +237 -0
  48. package/dist/routing/index.d.ts +1 -1
  49. package/dist/routing/index.js +1 -1
  50. package/dist/routing/modelCatalog.d.ts +79 -0
  51. package/dist/routing/modelCatalog.js +382 -0
  52. package/dist/telemetry/tracers.d.ts +1 -0
  53. package/dist/telemetry/tracers.js +1 -0
  54. package/dist/types/classifierRouter.d.ts +160 -4
  55. package/dist/types/cli.d.ts +17 -1
  56. package/dist/types/config.d.ts +12 -0
  57. package/dist/types/context.d.ts +55 -1
  58. package/dist/types/decision.d.ts +200 -0
  59. package/dist/types/decision.js +21 -0
  60. package/dist/types/generate.d.ts +20 -0
  61. package/dist/types/index.d.ts +1 -0
  62. package/dist/types/index.js +2 -0
  63. package/dist/types/providers.d.ts +59 -1
  64. package/dist/types/providers.js +5 -0
  65. package/dist/types/rag.d.ts +48 -0
  66. package/dist/types/span.d.ts +11 -0
  67. package/dist/types/span.js +11 -0
  68. package/dist/types/stream.d.ts +13 -0
  69. package/dist/types/toolRouting.d.ts +56 -0
  70. package/dist/utils/decisionAnswers.d.ts +56 -0
  71. package/dist/utils/decisionAnswers.js +95 -0
  72. package/dist/utils/modelChoices.js +13 -1
  73. package/dist/utils/pricing.js +11 -0
  74. package/dist/utils/providerConfig.d.ts +4 -0
  75. package/dist/utils/providerConfig.js +17 -0
  76. package/docs-site/static/search-index.json +1 -1
  77. package/package.json +16 -3
@@ -13,15 +13,17 @@ function record(value) {
13
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
14
14
  }
15
15
  /**
16
- * A turn that cannot legally open a history.
16
+ * A turn that cannot open a history.
17
17
  *
18
- * The Messages API rejects a conversation whose first message is not from the
19
- * user, so truncation that stops on an assistant turn would trade a local
20
- * refusal for an upstream 400. Items with no role (Codex `function_call` and
21
- * `function_call_output`) are not turns and are left alone.
18
+ * Verified against the live API: an assistant turn at the head is accepted
19
+ * (plain, carrying a complete tool pair, repeated, or opening with a signed
20
+ * `thinking` block). A `role: "system"` message is not — the Messages API
21
+ * requires the initial system prompt in the top-level `system` parameter.
22
+ * Items with no role (Codex `function_call` / `function_call_output`) are not
23
+ * turns and are left alone.
22
24
  */
23
- function isNonUserTurn(item) {
24
- return record(item) && typeof item.role === "string" && item.role !== "user";
25
+ function isDirectiveTurn(item) {
26
+ return record(item) && item.role === "system";
25
27
  }
26
28
  function blocksOf(item) {
27
29
  return Array.isArray(item.content) ? item.content : [item];
@@ -153,10 +155,13 @@ export function truncateHistoryForBudget(args) {
153
155
  if (removedUnits === 0) {
154
156
  return unchanged;
155
157
  }
156
- // Advance by whole units so the kept history opens on a user turn without
157
- // stranding a tool call from its answer.
158
+ // Only leading system directives are skipped. Advancing to the next *user*
159
+ // turn instead would discard every tool exchange in between, which in an
160
+ // agentic history is most of the conversation; an assistant head is
161
+ // accepted upstream, and unit grouping already keeps every `tool_result`
162
+ // with its `tool_use`.
158
163
  while (removedUnits < removable &&
159
- isNonUserTurn(items[units[removedUnits][0]])) {
164
+ isDirectiveTurn(items[units[removedUnits][0]])) {
160
165
  removedUnits += 1;
161
166
  }
162
167
  const keptIndices = units.slice(removedUnits).flat();
@@ -25,7 +25,7 @@
25
25
  * console.log(response.answer, response.sources);
26
26
  * ```
27
27
  */
28
- import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats, MultiModalRAGConfig, MultiModalQuery, MultiModalSearchResult } from "../../types/index.js";
28
+ import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats, MultiModalRAGConfig, MultiModalQuery, MultiModalSearchResult, DecisionCallerFn } from "../../types/index.js";
29
29
  import { MDocument } from "../document/MDocument.js";
30
30
  export declare class RAGPipeline {
31
31
  private id;
@@ -155,4 +155,6 @@ export declare function createRAGPipeline(options: {
155
155
  enableHybrid?: boolean;
156
156
  enableGraph?: boolean;
157
157
  multiModal?: MultiModalRAGConfig;
158
+ /** Fail-open decision caller for per-query retrieval planning. */
159
+ decide?: DecisionCallerFn;
158
160
  }): RAGPipeline;
@@ -32,6 +32,7 @@ import { InMemoryVectorStore } from "../retrieval/vectorQueryTool.js";
32
32
  import { InMemoryBM25Index, createHybridSearch, } from "../retrieval/hybridSearch.js";
33
33
  import { GraphRAG } from "../graphRag/graphRAG.js";
34
34
  import { rerank } from "../reranker/reranker.js";
35
+ import { decideSearchPlan } from "../retrieval/searchDecision.js";
35
36
  import { ProviderFactory } from "../../factories/providerFactory.js";
36
37
  import { ImageLoader } from "../document/imageLoader.js";
37
38
  import { ImageProcessor } from "../../utils/imageProcessor.js";
@@ -250,10 +251,53 @@ export class RAGPipeline {
250
251
  try {
251
252
  await this.ensureInitialized();
252
253
  const startTime = Date.now();
253
- const topK = options?.topK || this.config.defaultTopK || 5;
254
- const useHybrid = options?.hybrid ?? this.config.enableHybridSearch;
255
- const useGraph = options?.graph ?? this.config.enableGraphRAG;
256
- const useRerank = options?.rerank ?? this.config.enableReranking;
254
+ let topK = options?.topK || this.config.defaultTopK || 5;
255
+ let useHybrid = options?.hybrid ?? this.config.enableHybridSearch;
256
+ let useGraph = options?.graph ?? this.config.enableGraphRAG;
257
+ let useRerank = options?.rerank ?? this.config.enableReranking;
258
+ // Per-query retrieval planning. The four knobs above are otherwise
259
+ // static config, identical for "what is the refund window?" and "how
260
+ // does billing relate to entitlements?" — one precise passage versus a
261
+ // survey across documents. A decision model answers all four for THIS
262
+ // query in one ~400ms round trip.
263
+ //
264
+ // Precedence is strict and one-directional: an explicit QueryOptions
265
+ // field always wins, and the plan can only select among capabilities
266
+ // this pipeline was configured with. It never enables one that is not
267
+ // set up, because `graph: true` on a pipeline with no graph index is a
268
+ // failure rather than a plan.
269
+ let plan = null;
270
+ if (this.config.decide && options?.plan !== false) {
271
+ try {
272
+ plan = await decideSearchPlan(query, this.config.decide, {
273
+ defaultTopK: topK,
274
+ canHybrid: !!this.config.enableHybridSearch && !!this.hybridSearch,
275
+ canGraph: !!this.config.enableGraphRAG,
276
+ canRerank: !!this.config.enableReranking && !!this.config.rerankingModel,
277
+ });
278
+ }
279
+ catch (planError) {
280
+ logger.debug("[RAGPipeline] Search planning failed, using config", {
281
+ error: planError instanceof Error
282
+ ? planError.message
283
+ : String(planError),
284
+ });
285
+ }
286
+ }
287
+ if (plan) {
288
+ if (options?.topK === undefined && plan.topK !== undefined) {
289
+ topK = plan.topK;
290
+ }
291
+ if (options?.hybrid === undefined && plan.hybrid !== undefined) {
292
+ useHybrid = plan.hybrid;
293
+ }
294
+ if (options?.graph === undefined && plan.graph !== undefined) {
295
+ useGraph = plan.graph;
296
+ }
297
+ if (options?.rerank === undefined && plan.rerank !== undefined) {
298
+ useRerank = plan.rerank;
299
+ }
300
+ }
257
301
  let results;
258
302
  let retrievalMethod = "vector";
259
303
  // Generate query embedding
@@ -338,6 +382,7 @@ export class RAGPipeline {
338
382
  retrievalMethod,
339
383
  chunksRetrieved: results.length,
340
384
  reranked,
385
+ ...(plan ? { plan } : {}),
341
386
  },
342
387
  };
343
388
  span.durationMs = Date.now() - spanStartTime;
@@ -347,6 +392,21 @@ export class RAGPipeline {
347
392
  "rag.retrieval_method": retrievalMethod,
348
393
  "rag.results_count": results.length,
349
394
  "rag.reranked": reranked,
395
+ // The EFFECTIVE knobs, after planning and caller overrides. The span
396
+ // recorded the configured values at creation; without these a reader
397
+ // cannot tell a planned query from an unplanned one, which is the
398
+ // whole question when a fail-open path stops firing.
399
+ "rag.effective_top_k": topK,
400
+ "rag.planned": plan !== null,
401
+ ...(plan
402
+ ? {
403
+ "rag.plan_model": plan.model,
404
+ "rag.plan_latency_ms": plan.latencyMs,
405
+ ...(plan.breadthLevel !== undefined
406
+ ? { "rag.plan_breadth": plan.breadthLevel }
407
+ : {}),
408
+ }
409
+ : {}),
350
410
  };
351
411
  getMetricsAggregator().recordSpan(endedSpan);
352
412
  return response;
@@ -788,5 +848,6 @@ export function createRAGPipeline(options) {
788
848
  enableHybridSearch: options.enableHybrid,
789
849
  enableGraphRAG: options.enableGraph,
790
850
  multiModal: options.multiModal,
851
+ decide: options.decide,
791
852
  });
792
853
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Per-search retrieval planning.
3
+ *
4
+ * `RAGPipeline.query()` resolves four knobs — `topK`, `hybrid`, `graph`,
5
+ * `rerank` — and every one of them is config with an optional per-call
6
+ * override. Nothing inspects the query. The same plan is used for "what is
7
+ * the refund window?" (one precise passage, an exact phrase worth matching
8
+ * lexically) and "how does billing relate to entitlements?" (many passages,
9
+ * relationships between documents).
10
+ *
11
+ * One decision request answers all four for the query in hand, in ~400ms.
12
+ * Every answer is a *suggestion*: an explicit per-call option always wins,
13
+ * and a capability the pipeline was not configured with can never be turned
14
+ * on by a model — `graph: true` against a pipeline with no graph index would
15
+ * be a failure, not a plan.
16
+ *
17
+ * @module rag/retrieval/searchDecision
18
+ */
19
+ import type { DecisionCallerFn, SearchPlanCapabilities, SearchPlanResult } from "../../types/index.js";
20
+ /**
21
+ * Ask how this particular query should be retrieved.
22
+ *
23
+ * Returns `null` when nothing should change — no decision provider, a failed
24
+ * call, or an answer set that suggests nothing. Every returned field is
25
+ * optional and means "use this unless the caller said otherwise".
26
+ */
27
+ export declare function decideSearchPlan(query: string, decide: DecisionCallerFn, capabilities: SearchPlanCapabilities): Promise<SearchPlanResult | null>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Per-search retrieval planning.
3
+ *
4
+ * `RAGPipeline.query()` resolves four knobs — `topK`, `hybrid`, `graph`,
5
+ * `rerank` — and every one of them is config with an optional per-call
6
+ * override. Nothing inspects the query. The same plan is used for "what is
7
+ * the refund window?" (one precise passage, an exact phrase worth matching
8
+ * lexically) and "how does billing relate to entitlements?" (many passages,
9
+ * relationships between documents).
10
+ *
11
+ * One decision request answers all four for the query in hand, in ~400ms.
12
+ * Every answer is a *suggestion*: an explicit per-call option always wins,
13
+ * and a capability the pipeline was not configured with can never be turned
14
+ * on by a model — `graph: true` against a pipeline with no graph index would
15
+ * be a failure, not a plan.
16
+ *
17
+ * @module rag/retrieval/searchDecision
18
+ */
19
+ import { gateDecisionBoolean, readDecisionScore, } from "../../utils/decisionAnswers.js";
20
+ import { logger } from "../../utils/logger.js";
21
+ /**
22
+ * How many passages the question needs, narrowest → widest, as multiples of
23
+ * the configured default. A `score` rather than a number: a decision model
24
+ * places things on an ordered scale well and reads digits as text badly.
25
+ */
26
+ const BREADTH_CRITERIA = [
27
+ "One specific fact, definition or value. A single passage answers it completely.",
28
+ "A handful of related points — a procedure, a short comparison, one topic explained.",
29
+ "Several distinct areas that each need their own supporting passage.",
30
+ "A broad survey that needs evidence from across the whole corpus.",
31
+ ];
32
+ /** Multiplier applied to the configured topK, indexed by breadth level. */
33
+ const BREADTH_MULTIPLIER = [0.5, 1, 1.5, 2.5];
34
+ /** Below this the breadth reading is ignored and the configured topK stands. */
35
+ const MIN_BREADTH_CONFIDENCE = 0.5;
36
+ /** Absolute bounds on a suggested topK, whatever the multiplier produces. */
37
+ const MIN_TOP_K = 1;
38
+ const MAX_TOP_K = 50;
39
+ /**
40
+ * Ask how this particular query should be retrieved.
41
+ *
42
+ * Returns `null` when nothing should change — no decision provider, a failed
43
+ * call, or an answer set that suggests nothing. Every returned field is
44
+ * optional and means "use this unless the caller said otherwise".
45
+ */
46
+ export async function decideSearchPlan(query, decide, capabilities) {
47
+ if (!query.trim()) {
48
+ return null;
49
+ }
50
+ const questions = {
51
+ breadth: {
52
+ type: "score",
53
+ instructions: "How much supporting material does answering this question require from a document collection?",
54
+ criteria: BREADTH_CRITERIA,
55
+ },
56
+ };
57
+ // Only ask about a capability the pipeline actually has. An answer nobody
58
+ // can act on costs input tokens and invites the mistake of acting on it.
59
+ if (capabilities.canHybrid) {
60
+ questions.hybrid = {
61
+ type: "boolean",
62
+ instructions: "This question contains exact terms that must be matched literally — an identifier, error code, file name, version number, API name, or a quoted phrase — rather than only a topic to match by meaning.",
63
+ };
64
+ }
65
+ if (capabilities.canGraph) {
66
+ questions.graph = {
67
+ type: "boolean",
68
+ instructions: "Answering this requires connecting information that lives in separate documents, such as how two things relate, what depends on what, or tracing a chain across sources.",
69
+ };
70
+ }
71
+ if (capabilities.canRerank) {
72
+ questions.rerank = {
73
+ type: "boolean",
74
+ instructions: "This question is specific enough that the ORDER of the retrieved passages matters — a nearly-right passage would produce a wrong answer, so precision is worth an extra ranking pass.",
75
+ };
76
+ }
77
+ const result = await decide({
78
+ state: { query: query.slice(0, 4000) },
79
+ // The map is assembled dynamically above; its members are all valid
80
+ // question shapes, which the cast asserts once here rather than at four
81
+ // separate construction sites.
82
+ questions: questions,
83
+ timeoutMs: capabilities.timeoutMs,
84
+ });
85
+ if (!result) {
86
+ return null;
87
+ }
88
+ const plan = {
89
+ model: result.model,
90
+ latencyMs: result.latencyMs,
91
+ };
92
+ const breadth = readDecisionScore(result.answers, "breadth");
93
+ if (breadth && breadth.confidence >= MIN_BREADTH_CONFIDENCE) {
94
+ const level = Math.max(0, Math.min(BREADTH_MULTIPLIER.length - 1, Math.round(breadth.score)));
95
+ const multiplier = BREADTH_MULTIPLIER[level] ?? 1;
96
+ plan.topK = Math.max(MIN_TOP_K, Math.min(MAX_TOP_K, Math.round(capabilities.defaultTopK * multiplier)));
97
+ plan.breadthLevel = level;
98
+ }
99
+ if (capabilities.canHybrid) {
100
+ plan.hybrid = gateDecisionBoolean(result.answers, "hybrid");
101
+ }
102
+ if (capabilities.canGraph) {
103
+ plan.graph = gateDecisionBoolean(result.answers, "graph");
104
+ }
105
+ if (capabilities.canRerank) {
106
+ plan.rerank = gateDecisionBoolean(result.answers, "rerank");
107
+ }
108
+ const suggested = plan.topK !== undefined ||
109
+ plan.hybrid !== undefined ||
110
+ plan.graph !== undefined ||
111
+ plan.rerank !== undefined;
112
+ if (!suggested) {
113
+ return null;
114
+ }
115
+ logger.debug("[RAG] Search plan decided", {
116
+ model: result.model,
117
+ latencyMs: result.latencyMs,
118
+ topK: plan.topK,
119
+ hybrid: plan.hybrid,
120
+ graph: plan.graph,
121
+ rerank: plan.rerank,
122
+ });
123
+ return plan;
124
+ }
@@ -11,18 +11,52 @@
11
11
  * The only data dependency is the read-only `ModelResolver` registry lookup,
12
12
  * used purely for metadata enrichment and never for provider construction.
13
13
  */
14
- import type { ClassifierRouterConfig, ClassifierRouterDecision, ClassifierRouterDeps, ClassifierRouterInput } from "../types/index.js";
14
+ import type { ClassifierRouterConfig, ClassifierRouterDecision, ClassifierRouterDeps, ClassifierRouterInput, ClassifierRouterPoolMember } from "../types/index.js";
15
15
  export declare class ClassifierRouter {
16
16
  private readonly config;
17
17
  private readonly deps;
18
18
  private readonly metaCache;
19
+ /**
20
+ * The effective pool: what the host declared, plus catalogue-derived members
21
+ * when `catalog.enabled`. Mutable so a long-lived host can widen or narrow
22
+ * it without rebuilding the router — the constructor-frozen pool was the one
23
+ * thing preventing a runtime model catalogue.
24
+ */
25
+ private pool;
26
+ /** Registry index for candidate enrichment; built once, on first use. */
27
+ private registryIndex?;
19
28
  constructor(config: ClassifierRouterConfig, deps?: ClassifierRouterDeps);
29
+ /**
30
+ * Merge the declared pool with the catalogue. Declared members win on a
31
+ * duplicate `provider/model`, because a host that spelled a member out has
32
+ * said something about it (a region, a description, an explicit tier) that
33
+ * the registry does not know.
34
+ */
35
+ private static composePool;
36
+ /**
37
+ * Replace the routable pool at runtime. Returns the new size.
38
+ *
39
+ * Clears the metadata cache, since a member's declared cost/quality is
40
+ * cached per `provider::model` and a replacement pool may declare different
41
+ * values for the same pair.
42
+ */
43
+ setPool(members: ClassifierRouterPoolMember[]): number;
44
+ /** The pool currently routed over, declared plus catalogue. */
45
+ getPool(): ClassifierRouterPoolMember[];
46
+ /** Rebuild the catalogue half of the pool (e.g. after credentials change). */
47
+ refreshCatalog(): number;
20
48
  /**
21
49
  * Classify the request and produce a combined model + tool decision, or
22
50
  * `null` when nothing should change. Never throws (fails open).
23
51
  */
24
52
  route(input: ClassifierRouterInput): Promise<ClassifierRouterDecision | null>;
25
- /** Run the configured strategy; LLM falls back to heuristic on failure. */
53
+ /**
54
+ * Resolve "auto" (the default) to a concrete strategy. Jev is preferred the
55
+ * moment a TypeSafe key is present; without one the behaviour is exactly
56
+ * what it was before Jev existed.
57
+ */
58
+ private resolveStrategy;
59
+ /** Run the chosen strategy; every one falls back to heuristic on failure. */
26
60
  private classify;
27
61
  /**
28
62
  * Build LLM-facing model descriptors + an id→member map so the classifier can
@@ -35,12 +69,50 @@ export declare class ClassifierRouter {
35
69
  * metadata scoring.
36
70
  */
37
71
  private selectModels;
72
+ /**
73
+ * Whether a directly-picked model clears the bar that applies to it.
74
+ *
75
+ * The bars are asymmetric because the two mistakes do not cost the same, and
76
+ * which one a pick risks depends on what the difficulty tier would otherwise
77
+ * have chosen. Picking something COSTLIER than the tier's own choice risks
78
+ * spending more than necessary, so it clears the low upgrade bar. Picking
79
+ * something CHEAPER risks handing the task to a model that cannot do it, so
80
+ * it must clear the high downgrade bar. Agreeing with the tier needs no bar
81
+ * at all.
82
+ *
83
+ * A strategy that reports no confidence for its pick — the LLM classifier —
84
+ * is honoured exactly as before. Its self-reported numbers are not
85
+ * calibrated, so a bar over them would be arithmetic on noise, and imposing
86
+ * one would silently change the behaviour of a shipped, unrelated strategy.
87
+ */
88
+ private pickClearsBar;
89
+ /** The difficulty-based ranking, independent of any direct pick. */
90
+ private rankForDifficulty;
91
+ /**
92
+ * Whether a member's context window can hold the estimated request. Unknown
93
+ * windows pass — the registry does not know every model, and starving the
94
+ * pool on missing metadata is worse than an occasional retry.
95
+ */
96
+ private fitsRequest;
97
+ /** Drop members too small for the request, unless that empties the pool. */
98
+ private withRoomFor;
38
99
  /** Candidate members for a difficulty: explicit tierMap or eligible pool. */
39
100
  private candidatesFor;
40
101
  /** Drop members that cannot satisfy the required capabilities (lenient). */
41
102
  private filterByCapabilities;
42
103
  /** Rank candidates best-first for the difficulty's strategy. */
43
104
  private rank;
105
+ /**
106
+ * Turn the classifier's context reading into a compaction threshold.
107
+ *
108
+ * Returns undefined — meaning "leave the 0.8 default alone" — whenever the
109
+ * feature is off, no scope was read, or the scope is the widest one. The
110
+ * mapping can only ever LOWER the threshold, which is enforced in
111
+ * `contextScopeToThreshold` rather than here, because the consequence of
112
+ * getting it wrong in the other direction is not recoverable: a
113
+ * `context_window` error puts the model into ModelPool's 10-year cooldown.
114
+ */
115
+ private selectContextBudget;
44
116
  /** Tool narrowing: per-difficulty directive, then classifier hints. */
45
117
  private selectTools;
46
118
  /**