@gamaze/hicortex 0.16.7 → 0.16.8

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/dist/llm.js CHANGED
@@ -2,6 +2,9 @@
2
2
  /**
3
3
  * Multi-provider LLM client for consolidation and distillation.
4
4
  *
5
+ * ONE model serves all phases (distill, reflect, classify, scoring) — #231.
6
+ * The 0.16.x per-tier split (distill, reflect, classify + base) is removed.
7
+ *
5
8
  * Resolution (resolveExplicitLlmConfig):
6
9
  * 1. Explicit config-file overrides (llmBaseUrl + llmApiKey + llmModel)
7
10
  * 2. Hicortex-specific env vars (HICORTEX_LLM_BASE_URL + HICORTEX_LLM_API_KEY + HICORTEX_LLM_MODEL)
@@ -17,15 +20,11 @@
17
20
  Object.defineProperty(exports, "__esModule", { value: true });
18
21
  exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
19
22
  exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
20
- exports.applyModelsBlock = applyModelsBlock;
21
23
  exports.applyTierTuningOverlay = applyTierTuningOverlay;
22
24
  exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
23
- exports.resolveClassifyProbeTarget = resolveClassifyProbeTarget;
24
25
  exports.findClaudeBinary = findClaudeBinary;
25
26
  exports.claudeCliConfig = claudeCliConfig;
26
27
  exports.probeOllama = probeOllama;
27
- exports.probeOllamaModel = probeOllamaModel;
28
- exports.resolveDistillFallback = resolveDistillFallback;
29
28
  const config_read_js_1 = require("./config-read.js");
30
29
  /**
31
30
  * Resolve LLM configuration from explicit config-file overrides or
@@ -47,7 +46,6 @@ function resolveExplicitLlmConfig(overrides) {
47
46
  baseUrl: overrides.llmBaseUrl,
48
47
  apiKey: overrides.llmApiKey,
49
48
  model: overrides.llmModel ?? "claude-haiku-4-5-20251001",
50
- reflectModel: overrides.reflectModel ?? overrides.llmModel ?? "claude-sonnet-4-6",
51
49
  provider,
52
50
  };
53
51
  }
@@ -61,7 +59,6 @@ function resolveExplicitLlmConfig(overrides) {
61
59
  baseUrl: hcBaseUrl,
62
60
  apiKey: hcApiKey,
63
61
  model: hcModel ?? "claude-haiku-4-5-20251001",
64
- reflectModel: process.env.HICORTEX_REFLECT_MODEL ?? hcModel ?? "claude-sonnet-4-6",
65
62
  provider,
66
63
  };
67
64
  }
@@ -74,129 +71,19 @@ function resolveExplicitLlmConfig(overrides) {
74
71
  */
75
72
  exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
76
73
  /**
77
- * Map from a `models.<tier>` name to the flat config keys it feeds. The base
78
- * tier is `score` score IS the base model today (completeFast reads
79
- * config.model), so it lands on the llm* keys and its `provider` is ignored
80
- * (the base provider comes from llmBackend / detectProvider, not config).
81
- * Tiers with a `provider` key (distill/reflect/classify) apply their apiKey +
82
- * provider through a baseUrl-gated overlay downstream; `score` (no provider
83
- * key) rides the base resolution.
84
- */
85
- const MODELS_TIER_KEYS = {
86
- score: { model: "llmModel", baseUrl: "llmBaseUrl", apiKey: "llmApiKey" },
87
- distill: { model: "distillModel", baseUrl: "distillBaseUrl", apiKey: "distillApiKey", provider: "distillProvider" },
88
- reflect: { model: "reflectModel", baseUrl: "reflectBaseUrl", apiKey: "reflectApiKey", provider: "reflectProvider" },
89
- classify: { model: "classifyModel", baseUrl: "classifyBaseUrl", apiKey: "classifyApiKey", provider: "classifyProvider" },
90
- };
91
- /**
92
- * Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
93
- * onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
94
- * already consumes. Nested overrides WIN over any flat key of the same name; every
95
- * non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
96
- * is preserved via spread. Pure: returns the SAME reference when there is no
97
- * `models` key, so this is a provable no-op for every existing install.
74
+ * Validate + copy the tuning keys (#220: maxTokens + enableThinking + numCtx +
75
+ * ollama flush) from the saved disk config onto a runtime LlmConfig. Called by
76
+ * BOTH LlmConfig construction sites the daemon in mcp-server.ts (runs
77
+ * distill) AND resolveSavedLlmConfig below (the nightly runs reflect +
78
+ * classify) so every process honors the keys, and a future site calling this
79
+ * inherits them by construction.
98
80
  *
99
- * Robust to a malformed config.json: a config that parses to a scalar, array,
100
- * or null is returned untouched (matching the pre-0.13.1 optional-chaining
101
- * tolerance this function must never throw at server/nightly boot).
102
- *
103
- * Fail-explicit (warn + skip, never throw): an invalid `models` value, an
104
- * unknown tier name, a non-object tier value, a non-string field value, a tier
105
- * apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
106
- * a dead score apiKey/provider under an ollama base.
107
- */
108
- function applyModelsBlock(saved) {
109
- // Guard the container itself first — `"models" in saved` throws a TypeError on
110
- // a truthy non-object (config.json = `true`/`5`/`"x"`); such configs must pass
111
- // through so the boot path degrades to recall-only exactly as before.
112
- if (typeof saved !== "object" || saved === null || Array.isArray(saved))
113
- return saved;
114
- if (!("models" in saved))
115
- return saved;
116
- const models = saved.models;
117
- if (typeof models !== "object" || models === null || Array.isArray(models)) {
118
- console.warn(`[hicortex] Ignoring invalid "models" config: expected an object of per-tier overrides, got ${Array.isArray(models) ? "array" : models === null ? "null" : typeof models}`);
119
- return saved;
120
- }
121
- const ollamaBase = saved.llmBackend === "ollama";
122
- const mapped = {};
123
- for (const [tier, value] of Object.entries(models)) {
124
- const keys = MODELS_TIER_KEYS[tier];
125
- if (!keys) {
126
- console.warn(`[hicortex] Ignoring unknown "models" tier "${tier}" (expected: score, distill, reflect, classify)`);
127
- continue;
128
- }
129
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
130
- console.warn(`[hicortex] Ignoring invalid "models.${tier}" override: expected an object with model/baseUrl/apiKey/provider`);
131
- continue;
132
- }
133
- const o = value;
134
- // Per-field string validation: a non-string value would map verbatim and
135
- // fail opaquely downstream (e.g. baseUrl: 11434), so drop it with a warning.
136
- const strField = (name) => {
137
- const v = o[name];
138
- if (v === undefined)
139
- return undefined;
140
- if (typeof v !== "string") {
141
- console.warn(`[hicortex] Ignoring non-string "models.${tier}.${name}" (expected a string)`);
142
- return undefined;
143
- }
144
- return v;
145
- };
146
- const model = strField("model");
147
- const baseUrl = strField("baseUrl");
148
- const apiKey = strField("apiKey");
149
- const provider = strField("provider");
150
- if (model !== undefined)
151
- mapped[keys.model] = model;
152
- if (baseUrl !== undefined)
153
- mapped[keys.baseUrl] = baseUrl;
154
- if (keys.provider) {
155
- // Overlay tier (distill/reflect/classify): the downstream overlay only
156
- // consumes apiKey/provider when the tier ALSO sets its own baseUrl.
157
- // Without one, they would silently bill to the base key — so warn + drop.
158
- if ((apiKey !== undefined || provider !== undefined) && baseUrl === undefined) {
159
- console.warn(`[hicortex] Ignoring "models.${tier}" apiKey/provider without a baseUrl: they only take effect when the tier sets its own baseUrl`);
160
- }
161
- else {
162
- if (apiKey !== undefined)
163
- mapped[keys.apiKey] = apiKey;
164
- if (provider !== undefined)
165
- mapped[keys.provider] = provider;
166
- }
167
- }
168
- else {
169
- // score = base tier: no separate provider key, and apiKey rides llmApiKey.
170
- if (provider !== undefined) {
171
- console.warn(`[hicortex] Ignoring "models.score.provider": the base provider comes from llmBackend (or is auto-detected from the endpoint)`);
172
- }
173
- if (apiKey !== undefined) {
174
- if (ollamaBase) {
175
- // The ollama base path hardcodes an empty api key and never reads
176
- // llmApiKey, so score.apiKey is dead there.
177
- console.warn(`[hicortex] Ignoring "models.score.apiKey": the base ollama path sends no api key`);
178
- }
179
- else {
180
- mapped[keys.apiKey] = apiKey;
181
- }
182
- }
183
- }
184
- }
185
- return { ...saved, ...mapped };
186
- }
187
- /**
188
- * Validate + copy the heavy-phase tuning keys (#220: maxTokens + enableThinking)
189
- * from the saved disk config onto a runtime LlmConfig. Called by BOTH LlmConfig
190
- * construction sites — the daemon in mcp-server.ts (runs distill) AND
191
- * resolveSavedLlmConfig below (the nightly runs reflect + classify) — so every
192
- * process that runs a heavy phase honors the keys, and a future site calling
193
- * this inherits them by construction.
194
- *
195
- * Both keys are optional; absent = call-site defaults (8192 / thinking kwarg
196
- * omitted). Wrong-typed values warn and are dropped (readPositiveConfig /
197
- * readStrictBoolean) — notably a JSON slip `"enableThinking": "false"` (string)
198
- * is rejected rather than coerced to truthy thinking-on, which would silently
199
- * invert the fix this key exists to apply.
81
+ * All keys are optional; absent = call-site defaults (maxTokens 8192, numCtx
82
+ * 8192, thinking kwarg omitted, flush off). Wrong-typed values warn and are
83
+ * dropped (readPositiveConfig / readStrictBoolean / readNonNegativeConfig)
84
+ * notably a JSON slip `"enableThinking": "false"` (string) is rejected rather
85
+ * than coerced to truthy thinking-on, which would silently invert the fix this
86
+ * key exists to apply.
200
87
  */
201
88
  function applyTierTuningOverlay(llmConfig, savedConfig) {
202
89
  if (!savedConfig)
@@ -209,7 +96,7 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
209
96
  llmConfig.enableThinking = thinking;
210
97
  }
211
98
  if (savedConfig.numCtx !== undefined) {
212
- llmConfig.numCtx = (0, config_read_js_1.readPositiveConfig)(savedConfig, "numCtx", 2048);
99
+ llmConfig.numCtx = (0, config_read_js_1.readPositiveConfig)(savedConfig, "numCtx", 8192);
213
100
  }
214
101
  if (savedConfig.ollamaFlushEvery !== undefined) {
215
102
  llmConfig.ollamaFlushEvery = (0, config_read_js_1.readNonNegativeConfig)(savedConfig, "ollamaFlushEvery", 0);
@@ -223,9 +110,8 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
223
110
  *
224
111
  * This is the SINGLE config path used by pipeline runs (nightly consolidation
225
112
  * and `hicortex relink`): named backends (claude-cli, ollama) first, then the
226
- * explicit-config/env fallthrough via resolveExplicitLlmConfig, then the
227
- * reflect endpoint overlay. Extracted verbatim from nightly.ts behavior
228
- * is identical to the pre-0.11 inline block.
113
+ * explicit-config/env fallthrough via resolveExplicitLlmConfig. One model
114
+ * serves all phases (#231) there is no per-tier overlay here.
229
115
  *
230
116
  * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
231
117
  * the binary can't be found, so callers can log a context-specific message.
@@ -235,7 +121,6 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
235
121
  * deterministically in tests without depending on the host filesystem.
236
122
  */
237
123
  function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
238
- savedConfig = applyModelsBlock(savedConfig);
239
124
  let llmConfig = null;
240
125
  if (savedConfig?.llmBackend === "claude-cli") {
241
126
  const claudePath = findBinary();
@@ -251,7 +136,6 @@ function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
251
136
  baseUrl: savedConfig.llmBaseUrl ?? "http://localhost:11434",
252
137
  apiKey: "",
253
138
  model: savedConfig.llmModel ?? "qwen3.5:4b",
254
- reflectModel: savedConfig.reflectModel ?? savedConfig.llmModel ?? "qwen3.5:4b",
255
139
  provider: "ollama",
256
140
  };
257
141
  }
@@ -260,71 +144,15 @@ function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
260
144
  llmBaseUrl: savedConfig?.llmBaseUrl,
261
145
  llmApiKey: savedConfig?.llmApiKey,
262
146
  llmModel: savedConfig?.llmModel,
263
- reflectModel: savedConfig?.reflectModel,
264
147
  });
265
148
  }
266
- if (llmConfig && savedConfig?.reflectBaseUrl) {
267
- llmConfig.reflectBaseUrl = savedConfig.reflectBaseUrl;
268
- llmConfig.reflectApiKey = savedConfig.reflectApiKey ?? llmConfig.apiKey;
269
- llmConfig.reflectProvider = savedConfig.reflectProvider ?? llmConfig.provider;
270
- }
271
- // Heavy-phase tuning (#220: maxTokens + enableThinking). The nightly runs
272
- // reflect + classify (consolidation), the daemon runs distill — both need the
273
- // keys, so the overlay is applied at both construction sites.
149
+ // Tuning overlay (#220: maxTokens + enableThinking + numCtx + flush). Applied
150
+ // at both construction sites (daemon + nightly) so every phase honors the keys.
274
151
  if (llmConfig) {
275
152
  applyTierTuningOverlay(llmConfig, savedConfig);
276
153
  }
277
- // Optional classify tier (memory tag classification). Same overlay pattern
278
- // as distillModel/distillBaseUrl: when absent, completeClassify falls back
279
- // to the reflect tier — zero behavior change for existing installs.
280
- if (llmConfig && savedConfig?.classifyModel) {
281
- llmConfig.classifyModel = savedConfig.classifyModel;
282
- }
283
- if (llmConfig && savedConfig?.classifyBaseUrl) {
284
- llmConfig.classifyBaseUrl = savedConfig.classifyBaseUrl;
285
- llmConfig.classifyApiKey = savedConfig.classifyApiKey ?? llmConfig.apiKey;
286
- llmConfig.classifyProvider = savedConfig.classifyProvider ?? llmConfig.provider;
287
- }
288
154
  return { config: llmConfig };
289
155
  }
290
- /**
291
- * Endpoint + model that memory tag classification will ACTUALLY use, for
292
- * pre-flight probing. Pure function — the single source of truth shared by
293
- * the nightly's contentDomainsReady gate and `hicortex classify-domains`.
294
- *
295
- * Mirrors LlmClient.completeClassify's routing:
296
- * - classify tier configured (classifyModel and/or classifyBaseUrl) →
297
- * classifyBaseUrl ?? reflectBaseUrl, classifyModel ?? reflectModel
298
- * - classify tier absent → the reflect tier (reflectBaseUrl/reflectModel),
299
- * exactly what completeReflect uses
300
- *
301
- * Returns null when no probe applies: only a SEPARATE Ollama endpoint can go
302
- * unreachable mid-run (API providers are cloud-reachable; the base endpoint
303
- * is not pre-flighted anywhere, matching distill/reflect behavior).
304
- *
305
- * `tier` tells callers which configuration produced the target — "reflect"
306
- * means the classification probe is identical to the reflect-stage probe and
307
- * its result can be reused.
308
- */
309
- function resolveClassifyProbeTarget(config) {
310
- const classifyConfigured = Boolean(config.classifyModel || config.classifyBaseUrl);
311
- if (classifyConfigured) {
312
- const baseUrl = config.classifyBaseUrl ?? config.reflectBaseUrl;
313
- const model = config.classifyModel ?? config.reflectModel;
314
- const provider = config.classifyBaseUrl
315
- ? (config.classifyProvider ?? config.provider)
316
- : (config.reflectProvider ?? config.provider); // riding the reflect endpoint
317
- if (baseUrl && provider === "ollama") {
318
- return { tier: "classify", baseUrl, model };
319
- }
320
- return null; // base endpoint or API provider — no probe
321
- }
322
- // Classify tier absent — classification delegates to completeReflect.
323
- if (config.reflectBaseUrl && (config.reflectProvider ?? config.provider) === "ollama") {
324
- return { tier: "reflect", baseUrl: config.reflectBaseUrl, model: config.reflectModel ?? config.model };
325
- }
326
- return null;
327
- }
328
156
  function detectProvider(url) {
329
157
  const u = url.toLowerCase();
330
158
  if (u.includes("ollama") || u.includes(":11434"))
@@ -372,7 +200,6 @@ function claudeCliConfig(claudePath) {
372
200
  baseUrl: claudePath,
373
201
  apiKey: "",
374
202
  model: "haiku",
375
- reflectModel: "haiku",
376
203
  provider: "claude-cli",
377
204
  };
378
205
  }
@@ -404,96 +231,6 @@ async function probeOllama(baseUrl = "http://localhost:11434") {
404
231
  return null;
405
232
  }
406
233
  }
407
- /**
408
- * Pre-flight health check for a specific Ollama endpoint + model.
409
- * Returns { ok, reason } so callers can log a clear abort message.
410
- *
411
- * - `ok: true` — endpoint reachable AND the requested model appears in
412
- * `/api/tags`. Safe to proceed with a batch distillation run.
413
- * - `ok: false, reason: "unreachable"` — network failure or non-2xx.
414
- * - `ok: false, reason: "model_missing"` — endpoint is up but the
415
- * model isn't listed (the exact case that caused data loss when
416
- * a remote Ollama box didn't have the distill model loaded).
417
- *
418
- * Matches on exact name OR name prefix ("qwen3.5:35b" matches "qwen3.5:35b-a3b").
419
- */
420
- async function probeOllamaModel(baseUrl, modelName) {
421
- try {
422
- const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, {
423
- signal: AbortSignal.timeout(5000),
424
- });
425
- if (!resp.ok)
426
- return { ok: false, reason: "unreachable" };
427
- const data = (await resp.json());
428
- const models = data.models ?? [];
429
- const found = models.some((m) => m.name === modelName || m.name.startsWith(modelName + ":"));
430
- return found ? { ok: true } : { ok: false, reason: "model_missing" };
431
- }
432
- catch {
433
- return { ok: false, reason: "unreachable" };
434
- }
435
- }
436
- /**
437
- * Resolve the distillation endpoint before a /distill request.
438
- *
439
- * @param config LlmConfig (mutated in "local" mode when fallback is used)
440
- * @param mode
441
- * "strict" (default) — when a separate distillBaseUrl is configured and its
442
- * Ollama probe fails, return "abort" immediately WITHOUT mutating config.
443
- * The session is not distilled now; the nightly watermark is not advanced,
444
- * so the session is re-shipped on the next run (harness stores retain raw
445
- * for 30–90 days — the retry IS the queue). Prefer this to producing
446
- * low-quality memories from a weak fallback model.
447
- * "local" — legacy 0.9.0 behaviour: fall back to the base endpoint (local
448
- * Ollama or API provider) when the remote is down. Mutates config IN PLACE
449
- * to repoint distill* at the fallback.
450
- *
451
- * Returns:
452
- * "ok" — remote distill endpoint healthy, or no separate endpoint set
453
- * "fellback" — ("local" mode only) remote down; distill redirected to base
454
- * "abort" — remote down and fallback not allowed (strict) or both down (local)
455
- */
456
- async function resolveDistillFallback(config, mode = "strict") {
457
- const distillProvider = config.distillProvider ?? config.provider;
458
- // Only a remote Ollama distill endpoint can go unreachable mid-run; API
459
- // providers are cloud-reachable and need no fallback.
460
- if (!config.distillBaseUrl || distillProvider !== "ollama")
461
- return "ok";
462
- const distillModel = config.distillModel ?? config.model;
463
- const remote = await probeOllamaModel(config.distillBaseUrl, distillModel);
464
- if (remote.ok)
465
- return "ok";
466
- const reason = remote.reason === "unreachable"
467
- ? `remote distill endpoint unreachable (${config.distillBaseUrl})`
468
- : `remote distill model not loaded (${distillModel} on ${config.distillBaseUrl})`;
469
- if (mode === "strict") {
470
- // Do not mutate config. Log once and let the caller return 503 so the
471
- // nightly watermark stays put and the session is retried next run.
472
- console.error(`[hicortex] ABORT: ${reason} — session will be retried next run`);
473
- return "abort";
474
- }
475
- // "local" mode: fall back to the base endpoint.
476
- // If the base is Ollama, verify it is actually up before committing;
477
- // if the base is an API provider, it is cloud-reachable.
478
- if (config.provider === "ollama") {
479
- const local = await probeOllamaModel(config.baseUrl, config.model);
480
- if (!local.ok) {
481
- console.error(`[hicortex] ABORT: ${reason}, and local fallback (${config.model} on ${config.baseUrl}) also unavailable — retry next run`);
482
- return "abort";
483
- }
484
- config.distillBaseUrl = config.baseUrl;
485
- }
486
- else {
487
- // Base is an API provider — route distill through it (no separate baseUrl).
488
- config.distillBaseUrl = undefined;
489
- }
490
- config.distillModel = config.model;
491
- config.distillProvider = config.provider;
492
- config.distillApiKey = config.apiKey;
493
- console.warn(`[hicortex] ${reason} — falling back to base endpoint for distillation ` +
494
- `(${config.provider}/${config.model}). Lower quality, but capture continues.`);
495
- return "fellback";
496
- }
497
234
  // ---------------------------------------------------------------------------
498
235
  // LLM Client class
499
236
  // ---------------------------------------------------------------------------
@@ -508,12 +245,11 @@ class RateLimitError extends Error {
508
245
  }
509
246
  }
510
247
  exports.RateLimitError = RateLimitError;
511
- // Rate-limit backoff is shared across all LlmClient instances that target the
512
- // same endpoint, keyed by provider@baseUrl. completeWithOverride() spins up a
513
- // throwaway client per call for the distill/reflect/classify override tiers;
514
- // with per-instance state each throwaway started un-rate-limited and re-hit a
515
- // 429'd provider immediately, defeating the backoff on exactly the configs
516
- // (e.g. z.ai via distillBaseUrl/reflectBaseUrl) that route through those tiers.
248
+ // Rate-limit backoff is keyed by provider@baseUrl at module scope so it is
249
+ // shared across any LlmClient instances that target the same endpoint (e.g. a
250
+ // daemon client + a future constructed client). One model serves all phases
251
+ // (#231), so in practice there is a single client per process today; the
252
+ // module-level map keeps the state shared correctly if that ever changes.
517
253
  const rateLimitedUntilByEndpoint = new Map();
518
254
  class LlmClient {
519
255
  config;
@@ -545,19 +281,15 @@ class LlmClient {
545
281
  throw new RateLimitError(retryMs);
546
282
  }
547
283
  /**
548
- * Fast-tier completion (importance scoring, simple tasks).
284
+ * Fast-tier completion (importance scoring, simple tasks). One model serves
285
+ * all phases (#231); numCtx + enableThinking are read from config directly
286
+ * inside completeOnce's per-provider dispatch, not threaded here. The periodic
287
+ * ollama flush stays (provider-gated) — it is a scoring-call-count cadence and
288
+ * scoring is the highest-frequency call, so this is where the flush belongs.
549
289
  */
550
290
  async completeFast(prompt, maxTokens) {
551
- const tokens = maxTokens ?? this.config.maxTokens ?? 2048;
552
- // Scoring is the fast tier: numCtx (default 2048, ~850-token prompts) is threaded
553
- // so ONLY this tier gets the smaller context window — the heavy tiers keep 32768
554
- // (preserves detectChunkSize's chunk-sizing). thinking is not threaded (scoring is
555
- // excluded from the thinking toggle; ollama is think:false regardless).
556
- const result = await this.complete(this.config.model, prompt, tokens, 600_000, undefined, this.config.numCtx ?? 2048);
557
- // Periodic flush: scoped to scoring (the fast tier) only — heavy tiers
558
- // (distill/reflect/classify) don't flush, avoiding a multi-minute pause
559
- // mid-distillation + unloading the wrong model. Override-routed tiers never
560
- // reach here (completeWithOverride builds a fresh client). See #229 review.
291
+ const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
292
+ const result = await this.complete(this.config.model, prompt, tokens, 600_000);
561
293
  const flushEvery = this.config.ollamaFlushEvery ?? 0;
562
294
  if (this.config.provider === "ollama" && flushEvery > 0) {
563
295
  this.ollamaCallCount++;
@@ -569,77 +301,30 @@ class LlmClient {
569
301
  return result;
570
302
  }
571
303
  /**
572
- * Reflect-tier completion (nightly reflection, needs reasoning).
573
- * Routes to reflectBaseUrl/reflectProvider if configured (e.g. remote Ollama with larger model).
304
+ * Reflect-tier completion (nightly reflection). One model serves all phases
305
+ * (#231) this is a thin wrapper kept for call-site readability.
574
306
  */
575
307
  async completeReflect(prompt, maxTokens) {
576
308
  const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
577
- const thinking = this.config.enableThinking;
578
- if (this.config.reflectBaseUrl) {
579
- return this.completeWithOverride(this.config.reflectBaseUrl, this.config.reflectApiKey ?? this.config.apiKey, this.config.reflectProvider ?? this.config.provider, this.config.reflectModel, prompt, tokens, 900_000, thinking);
580
- }
581
- return this.complete(this.config.reflectModel, prompt, tokens, 900_000, thinking);
309
+ return this.complete(this.config.model, prompt, tokens, 900_000);
582
310
  }
583
311
  /**
584
- * Distillation-tier completion (session knowledge extraction).
585
- * Routes to distillBaseUrl/distillProvider if configured (e.g. remote Ollama with faster model).
312
+ * Distillation-tier completion (session knowledge extraction). One model
313
+ * serves all phases (#231) thin wrapper kept for call-site readability.
586
314
  */
587
315
  async completeDistill(prompt, maxTokens) {
588
316
  const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
589
- // enableThinking is threaded explicitly (not read inside the shared
590
- // completeOpenAiCompat) so the chat_template_kwargs kwarg is scoped to the
591
- // heavy phases — distill/reflect/classify — and never reaches scoring. See #220.
592
- const thinking = this.config.enableThinking;
593
- if (this.config.distillBaseUrl) {
594
- return this.completeWithOverride(this.config.distillBaseUrl, this.config.distillApiKey ?? this.config.apiKey, this.config.distillProvider ?? this.config.provider, this.config.distillModel ?? this.config.model, prompt, tokens, 900_000, thinking);
595
- }
596
- return this.complete(this.config.distillModel ?? this.config.model, prompt, tokens, 900_000, thinking);
317
+ return this.complete(this.config.model, prompt, tokens, 900_000);
597
318
  }
598
319
  /**
599
- * Classification-tier completion (memory tag classification).
600
- *
601
- * Routing (same "optional dedicated model+baseUrl with fallback" pattern as
602
- * completeDistill; Ollama calls inherit think:false via completeOllama):
603
- * - Neither classifyModel nor classifyBaseUrl set → delegate to
604
- * completeReflect (exactly the pre-classify-tier behavior).
605
- * - classifyBaseUrl set → that endpoint, model classifyModel ?? reflectModel.
606
- * - Only classifyModel set → the classify model on the reflect endpoint
607
- * when one is configured, else on the base endpoint.
320
+ * Classification-tier completion (memory tag classification). One model
321
+ * serves all phases (#231) — thin wrapper kept for call-site readability.
608
322
  */
609
323
  async completeClassify(prompt, maxTokens) {
610
324
  const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
611
- const thinking = this.config.enableThinking;
612
- if (!this.config.classifyModel && !this.config.classifyBaseUrl) {
613
- return this.completeReflect(prompt, tokens);
614
- }
615
- const model = this.config.classifyModel ?? this.config.reflectModel;
616
- if (this.config.classifyBaseUrl) {
617
- return this.completeWithOverride(this.config.classifyBaseUrl, this.config.classifyApiKey ?? this.config.apiKey, this.config.classifyProvider ?? this.config.provider, model, prompt, tokens, 900_000, thinking);
618
- }
619
- if (this.config.reflectBaseUrl) {
620
- return this.completeWithOverride(this.config.reflectBaseUrl, this.config.reflectApiKey ?? this.config.apiKey, this.config.reflectProvider ?? this.config.provider, model, prompt, tokens, 900_000, thinking);
621
- }
622
- return this.complete(model, prompt, tokens, 900_000, thinking);
325
+ return this.complete(this.config.model, prompt, tokens, 900_000);
623
326
  }
624
- /**
625
- * Complete with overridden baseUrl/apiKey/provider (used for reflect tier with separate endpoint).
626
- * Creates a temporary LlmClient to avoid mutating shared config under concurrent calls.
627
- */
628
- async completeWithOverride(baseUrl, apiKey, provider, model, prompt, maxTokens, timeoutMs, thinking) {
629
- const tempClient = new LlmClient({
630
- ...this.config,
631
- baseUrl,
632
- apiKey,
633
- provider,
634
- });
635
- // numCtx intentionally NOT forwarded here: only the heavy tiers route through
636
- // completeWithOverride (there is no fastBaseUrl/fast-tier override), and they
637
- // must land on completeOllama's 32768 default to preserve detectChunkSize. If a
638
- // fast-tier override is ever added, thread numCtx here too — else the fast tier
639
- // would silently revert to 32768 (the original bug, reintroduced).
640
- return tempClient.complete(model, prompt, maxTokens, timeoutMs, thinking);
641
- }
642
- async complete(model, prompt, maxTokens, timeoutMs, thinking, numCtx) {
327
+ async complete(model, prompt, maxTokens, timeoutMs) {
643
328
  if (this.isRateLimited) {
644
329
  throw new RateLimitError(this.rateLimitedUntil - Date.now());
645
330
  }
@@ -647,7 +332,7 @@ class LlmClient {
647
332
  let lastErr;
648
333
  for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
649
334
  try {
650
- return await this.completeOnce(model, prompt, maxTokens, timeoutMs, thinking, numCtx);
335
+ return await this.completeOnce(model, prompt, maxTokens, timeoutMs);
651
336
  }
652
337
  catch (err) {
653
338
  lastErr = err instanceof Error ? err : new Error(String(err));
@@ -664,20 +349,18 @@ class LlmClient {
664
349
  }
665
350
  throw lastErr;
666
351
  }
667
- async completeOnce(model, prompt, maxTokens, timeoutMs, thinking, numCtx) {
352
+ async completeOnce(model, prompt, maxTokens, timeoutMs) {
668
353
  if (this.config.provider === "claude-cli") {
669
354
  return this.completeClaude(model, prompt, timeoutMs);
670
355
  }
671
356
  if (this.config.provider === "ollama") {
672
- return this.completeOllama(model, prompt, maxTokens, timeoutMs, numCtx);
357
+ return this.completeOllama(model, prompt, maxTokens, timeoutMs);
673
358
  }
674
359
  if (this.config.provider === "anthropic") {
675
360
  return this.completeAnthropic(model, prompt, maxTokens, timeoutMs);
676
361
  }
677
- // thinking is threaded from each heavy phase (distill/reflect/classify), so
678
- // the kwarg is emitted for those tiers on the openai-compat path; completeFast
679
- // (scoring) never passes it (PR #227 review F1).
680
- return this.completeOpenAiCompat(model, prompt, maxTokens, timeoutMs, thinking);
362
+ // enableThinking is read from config here (one value, all phases — #231).
363
+ return this.completeOpenAiCompat(model, prompt, maxTokens, timeoutMs);
681
364
  }
682
365
  /**
683
366
  * Claude CLI: shell out to `claude -p` for subscription users.
@@ -704,8 +387,9 @@ class LlmClient {
704
387
  }
705
388
  /**
706
389
  * Ollama: use /api/generate with think:false (important for qwen3.5 models).
390
+ * num_ctx is read from config (one value, all phases — #231; default 8192).
707
391
  */
708
- async completeOllama(model, prompt, maxTokens, timeoutMs, numCtx) {
392
+ async completeOllama(model, prompt, maxTokens, timeoutMs) {
709
393
  const url = `${this.config.baseUrl.replace(/\/$/, "")}/api/generate`;
710
394
  // Ollama can take minutes to process large contexts — use streaming to avoid
711
395
  // Node.js fetch headers timeout (default ~300s kills long Ollama inferences)
@@ -717,14 +401,11 @@ class LlmClient {
717
401
  prompt,
718
402
  stream: true,
719
403
  think: false,
720
- // num_ctx: threaded from completeFast (the fast/scoring tier) via the numCtx
721
- // param. The fast tier passes the numCtx config key (default 2048; scoring
722
- // prompts are ~850 tokens) to minimize the KV/prompt-cache footprint that, at
723
- // 32768, accumulated past available RAM on memory-constrained boxes during long
724
- // consolidations and swap-thrashed the nightly. The heavy tiers
725
- // (completeDistill/Reflect/Classify) do NOT pass numCtx → default 32768, which
726
- // preserves detectChunkSize's chunk-sizing (it packs ~60% of context per chunk).
727
- options: { num_predict: maxTokens, num_ctx: numCtx ?? 32768 },
404
+ // num_ctx: one value for all phases (#231), default 8192 — the point where
405
+ // context stops being the binding constraint for a sub-8B model on ollama
406
+ // (above it the SMALL_MODEL_MAX_CHUNK_CHARS speed cap binds instead). Also
407
+ // drives detectChunkSize, so the chunker and the request agree by construction.
408
+ options: { num_predict: maxTokens, num_ctx: this.config.numCtx ?? 8192 },
728
409
  }),
729
410
  signal: AbortSignal.timeout(timeoutMs),
730
411
  });
@@ -822,8 +503,9 @@ class LlmClient {
822
503
  }
823
504
  /**
824
505
  * OpenAI-compatible /v1/chat/completions (works for OpenAI, OpenRouter, etc).
506
+ * enableThinking is read from config here (one value, all phases — #231).
825
507
  */
826
- async completeOpenAiCompat(model, prompt, maxTokens, timeoutMs, thinking) {
508
+ async completeOpenAiCompat(model, prompt, maxTokens, timeoutMs) {
827
509
  const baseUrl = this.config.baseUrl.replace(/\/$/, "");
828
510
  // Some providers include the API version in the base URL already
829
511
  const hasVersion = /\/v\d+\/?$/.test(baseUrl);
@@ -837,11 +519,13 @@ class LlmClient {
837
519
  headers["Authorization"] = `Bearer ${this.config.apiKey}`;
838
520
  }
839
521
  // Qwen3 thinking mode: when on, the model can burn the whole token budget on
840
- // an unclosed <think> block and emit nothing (probed 2026-08-04). `thinking`
841
- // is threaded here ONLY from completeDistill, so the kwarg is emitted solely
842
- // for the distill tierreflect/classify/fast never send it (PR #227 F1).
843
- // mlx-lm forwards chat_template_kwargs to the chat template (mirrors the
844
- // ollama path's think:false). See #220.
522
+ // an unclosed <think> block and emit nothing (probed 2026-08-04). One value
523
+ // for all phases (#231): when set (true or false) the chat_template_kwargs
524
+ // kwarg rides every callso it is LOCAL-endpoint only (ollama, mlx-lm
525
+ // gateway); a cloud OpenAI/OpenRouter/Groq endpoint would 400 on the unknown
526
+ // field. provider cannot gate this (the MLX gateway is also provider:openai),
527
+ // so the operator leaves enableThinking unset for cloud endpoints. See #220.
528
+ const thinking = this.config.enableThinking;
845
529
  const body = {
846
530
  model,
847
531
  messages: [{ role: "user", content: prompt }],