@gamaze/hicortex 0.16.7 → 0.16.9
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 +8 -28
- package/dist/classify-domains.d.ts +12 -13
- package/dist/classify-domains.js +12 -40
- package/dist/consolidate.d.ts +5 -5
- package/dist/consolidate.js +3 -4
- package/dist/distiller.d.ts +12 -4
- package/dist/distiller.js +20 -15
- package/dist/domain-classify.d.ts +7 -7
- package/dist/domain-classify.js +7 -7
- package/dist/features.d.ts +12 -2
- package/dist/features.js +14 -3
- package/dist/index.js +5 -1
- package/dist/init.d.ts +9 -10
- package/dist/init.js +11 -14
- package/dist/lessons-context.d.ts +2 -0
- package/dist/lessons-context.js +8 -2
- package/dist/llm.d.ts +35 -147
- package/dist/llm.js +63 -379
- package/dist/mcp-server.js +12 -52
- package/dist/nightly-status.js +1 -2
- package/dist/nightly.js +19 -48
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.js +16 -3
- package/dist/telemetry.d.ts +9 -0
- package/dist/telemetry.js +3 -0
- package/dist/types.d.ts +30 -57
- package/package.json +1 -1
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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",
|
|
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
|
|
227
|
-
*
|
|
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
|
-
|
|
267
|
-
|
|
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
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
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 ??
|
|
552
|
-
|
|
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
|
|
573
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
678
|
-
|
|
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
|
|
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:
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
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
|
|
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).
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
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 call — so 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 }],
|