@gamaze/hicortex 0.16.6 → 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/README.md +7 -28
- package/dist/classify-domains.d.ts +12 -13
- package/dist/classify-domains.js +12 -40
- package/dist/config-read.d.ts +6 -0
- package/dist/config-read.js +15 -0
- 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/init.d.ts +9 -10
- package/dist/init.js +11 -14
- package/dist/llm.d.ts +38 -148
- package/dist/llm.js +83 -394
- package/dist/mcp-server.js +12 -52
- package/dist/nightly-status.js +1 -2
- package/dist/nightly.js +9 -48
- package/dist/types.d.ts +32 -62
- 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,10 +96,10 @@ 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
|
-
llmConfig.ollamaFlushEvery = (0, config_read_js_1.
|
|
102
|
+
llmConfig.ollamaFlushEvery = (0, config_read_js_1.readNonNegativeConfig)(savedConfig, "ollamaFlushEvery", 0);
|
|
216
103
|
}
|
|
217
104
|
if (savedConfig.ollamaFlushWaitMs !== undefined) {
|
|
218
105
|
llmConfig.ollamaFlushWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "ollamaFlushWaitMs", 180000);
|
|
@@ -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,88 +281,50 @@ 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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
291
|
+
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
292
|
+
const result = await this.complete(this.config.model, prompt, tokens, 600_000);
|
|
293
|
+
const flushEvery = this.config.ollamaFlushEvery ?? 0;
|
|
294
|
+
if (this.config.provider === "ollama" && flushEvery > 0) {
|
|
295
|
+
this.ollamaCallCount++;
|
|
296
|
+
if (this.ollamaCallCount >= flushEvery) {
|
|
297
|
+
await this.flushOllama(this.config.model);
|
|
298
|
+
this.ollamaCallCount = 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
557
302
|
}
|
|
558
303
|
/**
|
|
559
|
-
* Reflect-tier completion (nightly reflection
|
|
560
|
-
*
|
|
304
|
+
* Reflect-tier completion (nightly reflection). One model serves all phases
|
|
305
|
+
* (#231) — this is a thin wrapper kept for call-site readability.
|
|
561
306
|
*/
|
|
562
307
|
async completeReflect(prompt, maxTokens) {
|
|
563
308
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
564
|
-
|
|
565
|
-
if (this.config.reflectBaseUrl) {
|
|
566
|
-
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);
|
|
567
|
-
}
|
|
568
|
-
return this.complete(this.config.reflectModel, prompt, tokens, 900_000, thinking);
|
|
309
|
+
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
569
310
|
}
|
|
570
311
|
/**
|
|
571
|
-
* Distillation-tier completion (session knowledge extraction).
|
|
572
|
-
*
|
|
312
|
+
* Distillation-tier completion (session knowledge extraction). One model
|
|
313
|
+
* serves all phases (#231) — thin wrapper kept for call-site readability.
|
|
573
314
|
*/
|
|
574
315
|
async completeDistill(prompt, maxTokens) {
|
|
575
316
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
576
|
-
|
|
577
|
-
// completeOpenAiCompat) so the chat_template_kwargs kwarg is scoped to the
|
|
578
|
-
// heavy phases — distill/reflect/classify — and never reaches scoring. See #220.
|
|
579
|
-
const thinking = this.config.enableThinking;
|
|
580
|
-
if (this.config.distillBaseUrl) {
|
|
581
|
-
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);
|
|
582
|
-
}
|
|
583
|
-
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);
|
|
584
318
|
}
|
|
585
319
|
/**
|
|
586
|
-
* Classification-tier completion (memory tag classification).
|
|
587
|
-
*
|
|
588
|
-
* Routing (same "optional dedicated model+baseUrl with fallback" pattern as
|
|
589
|
-
* completeDistill; Ollama calls inherit think:false via completeOllama):
|
|
590
|
-
* - Neither classifyModel nor classifyBaseUrl set → delegate to
|
|
591
|
-
* completeReflect (exactly the pre-classify-tier behavior).
|
|
592
|
-
* - classifyBaseUrl set → that endpoint, model classifyModel ?? reflectModel.
|
|
593
|
-
* - Only classifyModel set → the classify model on the reflect endpoint
|
|
594
|
-
* 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.
|
|
595
322
|
*/
|
|
596
323
|
async completeClassify(prompt, maxTokens) {
|
|
597
324
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
598
|
-
|
|
599
|
-
if (!this.config.classifyModel && !this.config.classifyBaseUrl) {
|
|
600
|
-
return this.completeReflect(prompt, tokens);
|
|
601
|
-
}
|
|
602
|
-
const model = this.config.classifyModel ?? this.config.reflectModel;
|
|
603
|
-
if (this.config.classifyBaseUrl) {
|
|
604
|
-
return this.completeWithOverride(this.config.classifyBaseUrl, this.config.classifyApiKey ?? this.config.apiKey, this.config.classifyProvider ?? this.config.provider, model, prompt, tokens, 900_000, thinking);
|
|
605
|
-
}
|
|
606
|
-
if (this.config.reflectBaseUrl) {
|
|
607
|
-
return this.completeWithOverride(this.config.reflectBaseUrl, this.config.reflectApiKey ?? this.config.apiKey, this.config.reflectProvider ?? this.config.provider, model, prompt, tokens, 900_000, thinking);
|
|
608
|
-
}
|
|
609
|
-
return this.complete(model, prompt, tokens, 900_000, thinking);
|
|
325
|
+
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
610
326
|
}
|
|
611
|
-
|
|
612
|
-
* Complete with overridden baseUrl/apiKey/provider (used for reflect tier with separate endpoint).
|
|
613
|
-
* Creates a temporary LlmClient to avoid mutating shared config under concurrent calls.
|
|
614
|
-
*/
|
|
615
|
-
async completeWithOverride(baseUrl, apiKey, provider, model, prompt, maxTokens, timeoutMs, thinking) {
|
|
616
|
-
const tempClient = new LlmClient({
|
|
617
|
-
...this.config,
|
|
618
|
-
baseUrl,
|
|
619
|
-
apiKey,
|
|
620
|
-
provider,
|
|
621
|
-
});
|
|
622
|
-
// numCtx intentionally NOT forwarded here: only the heavy tiers route through
|
|
623
|
-
// completeWithOverride (there is no fastBaseUrl/fast-tier override), and they
|
|
624
|
-
// must land on completeOllama's 32768 default to preserve detectChunkSize. If a
|
|
625
|
-
// fast-tier override is ever added, thread numCtx here too — else the fast tier
|
|
626
|
-
// would silently revert to 32768 (the original bug, reintroduced).
|
|
627
|
-
return tempClient.complete(model, prompt, maxTokens, timeoutMs, thinking);
|
|
628
|
-
}
|
|
629
|
-
async complete(model, prompt, maxTokens, timeoutMs, thinking, numCtx) {
|
|
327
|
+
async complete(model, prompt, maxTokens, timeoutMs) {
|
|
630
328
|
if (this.isRateLimited) {
|
|
631
329
|
throw new RateLimitError(this.rateLimitedUntil - Date.now());
|
|
632
330
|
}
|
|
@@ -634,7 +332,7 @@ class LlmClient {
|
|
|
634
332
|
let lastErr;
|
|
635
333
|
for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
|
|
636
334
|
try {
|
|
637
|
-
return await this.completeOnce(model, prompt, maxTokens, timeoutMs
|
|
335
|
+
return await this.completeOnce(model, prompt, maxTokens, timeoutMs);
|
|
638
336
|
}
|
|
639
337
|
catch (err) {
|
|
640
338
|
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
@@ -651,20 +349,18 @@ class LlmClient {
|
|
|
651
349
|
}
|
|
652
350
|
throw lastErr;
|
|
653
351
|
}
|
|
654
|
-
async completeOnce(model, prompt, maxTokens, timeoutMs
|
|
352
|
+
async completeOnce(model, prompt, maxTokens, timeoutMs) {
|
|
655
353
|
if (this.config.provider === "claude-cli") {
|
|
656
354
|
return this.completeClaude(model, prompt, timeoutMs);
|
|
657
355
|
}
|
|
658
356
|
if (this.config.provider === "ollama") {
|
|
659
|
-
return this.completeOllama(model, prompt, maxTokens, timeoutMs
|
|
357
|
+
return this.completeOllama(model, prompt, maxTokens, timeoutMs);
|
|
660
358
|
}
|
|
661
359
|
if (this.config.provider === "anthropic") {
|
|
662
360
|
return this.completeAnthropic(model, prompt, maxTokens, timeoutMs);
|
|
663
361
|
}
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
// (scoring) never passes it (PR #227 review F1).
|
|
667
|
-
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);
|
|
668
364
|
}
|
|
669
365
|
/**
|
|
670
366
|
* Claude CLI: shell out to `claude -p` for subscription users.
|
|
@@ -691,8 +387,9 @@ class LlmClient {
|
|
|
691
387
|
}
|
|
692
388
|
/**
|
|
693
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).
|
|
694
391
|
*/
|
|
695
|
-
async completeOllama(model, prompt, maxTokens, timeoutMs
|
|
392
|
+
async completeOllama(model, prompt, maxTokens, timeoutMs) {
|
|
696
393
|
const url = `${this.config.baseUrl.replace(/\/$/, "")}/api/generate`;
|
|
697
394
|
// Ollama can take minutes to process large contexts — use streaming to avoid
|
|
698
395
|
// Node.js fetch headers timeout (default ~300s kills long Ollama inferences)
|
|
@@ -704,14 +401,11 @@ class LlmClient {
|
|
|
704
401
|
prompt,
|
|
705
402
|
stream: true,
|
|
706
403
|
think: false,
|
|
707
|
-
// num_ctx:
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
// (completeDistill/Reflect/Classify) do NOT pass numCtx → default 32768, which
|
|
713
|
-
// preserves detectChunkSize's chunk-sizing (it packs ~60% of context per chunk).
|
|
714
|
-
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 },
|
|
715
409
|
}),
|
|
716
410
|
signal: AbortSignal.timeout(timeoutMs),
|
|
717
411
|
});
|
|
@@ -746,20 +440,6 @@ class LlmClient {
|
|
|
746
440
|
catch { /* skip malformed lines */ }
|
|
747
441
|
}
|
|
748
442
|
}
|
|
749
|
-
// Flush ollama's accumulated memory every N calls. ollama's runner RSS grows
|
|
750
|
-
// ~171 MB/call and isn't freed between requests — on RAM-constrained boxes
|
|
751
|
-
// this swap-thrashes long consolidations. After the Nth call, unload the
|
|
752
|
-
// model (keep_alive:0) + wait for the runner to exit + release, so the next
|
|
753
|
-
// call reloads fresh (low RSS) instead of accumulating to thrash. Opt-in via
|
|
754
|
-
// ollamaFlushEvery (0 = off).
|
|
755
|
-
const flushEvery = this.config.ollamaFlushEvery ?? 0;
|
|
756
|
-
if (flushEvery > 0) {
|
|
757
|
-
this.ollamaCallCount++;
|
|
758
|
-
if (this.ollamaCallCount >= flushEvery) {
|
|
759
|
-
await this.flushOllama(model);
|
|
760
|
-
this.ollamaCallCount = 0;
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
443
|
return result.trim();
|
|
764
444
|
}
|
|
765
445
|
/**
|
|
@@ -767,10 +447,14 @@ class LlmClient {
|
|
|
767
447
|
* runner exits + releases its per-request RSS growth, then wait for the release
|
|
768
448
|
* before the next call reloads fresh. The runner takes >90 s to exit after
|
|
769
449
|
* keep_alive:0 (measured), so the wait is generous (ollamaFlushWaitMs, default
|
|
770
|
-
* 180 s).
|
|
450
|
+
* 180 s). Logs the flush so the wait is distinguishable from a hang. If the
|
|
451
|
+
* unload request fails (ollama down), the wait is skipped — no dead time for a
|
|
452
|
+
* release that can't have happened. See #229 review.
|
|
771
453
|
*/
|
|
772
454
|
async flushOllama(model) {
|
|
773
455
|
const url = `${this.config.baseUrl.replace(/\/$/, "")}/api/generate`;
|
|
456
|
+
const waitMs = this.config.ollamaFlushWaitMs ?? 180_000;
|
|
457
|
+
console.log(`[hicortex] ollama flush: unloading ${model} after ${this.ollamaCallCount} scoring calls, waiting ${waitMs / 1000}s for memory release…`);
|
|
774
458
|
try {
|
|
775
459
|
await fetch(url, {
|
|
776
460
|
method: "POST",
|
|
@@ -778,10 +462,12 @@ class LlmClient {
|
|
|
778
462
|
body: JSON.stringify({ model, keep_alive: 0 }),
|
|
779
463
|
signal: AbortSignal.timeout(60_000),
|
|
780
464
|
});
|
|
465
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
466
|
+
console.log(`[hicortex] ollama flush: complete`);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
console.warn(`[hicortex] ollama flush: unload request failed (ollama unreachable?) — skipping wait`);
|
|
781
470
|
}
|
|
782
|
-
catch { /* best-effort flush */ }
|
|
783
|
-
const waitMs = this.config.ollamaFlushWaitMs ?? 180_000;
|
|
784
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
785
471
|
}
|
|
786
472
|
/**
|
|
787
473
|
* Anthropic Messages API (/v1/messages).
|
|
@@ -817,8 +503,9 @@ class LlmClient {
|
|
|
817
503
|
}
|
|
818
504
|
/**
|
|
819
505
|
* OpenAI-compatible /v1/chat/completions (works for OpenAI, OpenRouter, etc).
|
|
506
|
+
* enableThinking is read from config here (one value, all phases — #231).
|
|
820
507
|
*/
|
|
821
|
-
async completeOpenAiCompat(model, prompt, maxTokens, timeoutMs
|
|
508
|
+
async completeOpenAiCompat(model, prompt, maxTokens, timeoutMs) {
|
|
822
509
|
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
823
510
|
// Some providers include the API version in the base URL already
|
|
824
511
|
const hasVersion = /\/v\d+\/?$/.test(baseUrl);
|
|
@@ -832,11 +519,13 @@ class LlmClient {
|
|
|
832
519
|
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
833
520
|
}
|
|
834
521
|
// Qwen3 thinking mode: when on, the model can burn the whole token budget on
|
|
835
|
-
// an unclosed <think> block and emit nothing (probed 2026-08-04).
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
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;
|
|
840
529
|
const body = {
|
|
841
530
|
model,
|
|
842
531
|
messages: [{ role: "user", content: prompt }],
|