@gamaze/hicortex 0.16.5 → 0.16.7

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.
@@ -23,3 +23,9 @@ export declare function readPositiveConfig(config: Record<string, unknown>, key:
23
23
  * treated as truthy.
24
24
  */
25
25
  export declare function readStrictBoolean(config: Record<string, unknown>, key: string): boolean | undefined;
26
+ /**
27
+ * Read a non-negative finite number (allows 0, unlike readPositiveConfig).
28
+ * Returns `def` when absent OR invalid. Used for keys where 0 is a valid "off"
29
+ * value (e.g. ollamaFlushEvery).
30
+ */
31
+ export declare function readNonNegativeConfig(config: Record<string, unknown>, key: string, def: number): number;
@@ -15,6 +15,7 @@
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.readPositiveConfig = readPositiveConfig;
17
17
  exports.readStrictBoolean = readStrictBoolean;
18
+ exports.readNonNegativeConfig = readNonNegativeConfig;
18
19
  /**
19
20
  * Read a positive finite number from a config object. Returns `def` when the
20
21
  * key is absent OR present-but-invalid (with a warn in the latter case).
@@ -43,3 +44,17 @@ function readStrictBoolean(config, key) {
43
44
  console.warn(`[hicortex] config "${key}" = ${String(v)} is not a boolean — ignored.`);
44
45
  return undefined;
45
46
  }
47
+ /**
48
+ * Read a non-negative finite number (allows 0, unlike readPositiveConfig).
49
+ * Returns `def` when absent OR invalid. Used for keys where 0 is a valid "off"
50
+ * value (e.g. ollamaFlushEvery).
51
+ */
52
+ function readNonNegativeConfig(config, key, def) {
53
+ const v = config[key];
54
+ if (v === undefined)
55
+ return def;
56
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
57
+ return v;
58
+ console.warn(`[hicortex] config "${key}" = ${String(v)} is not a non-negative finite number — using default ${def}.`);
59
+ return def;
60
+ }
package/dist/llm.d.ts CHANGED
@@ -31,6 +31,10 @@ export interface LlmConfig {
31
31
  enableThinking?: boolean;
32
32
  /** Context window for the ollama fast tier (completeOllama). Falls back to 2048. */
33
33
  numCtx?: number;
34
+ /** Flush ollama memory every N ollama calls (0 = off). See HicortexConfig.ollamaFlushEvery. */
35
+ ollamaFlushEvery?: number;
36
+ /** Ms to wait after an ollama flush for the runner to release. */
37
+ ollamaFlushWaitMs?: number;
34
38
  /** Optional separate endpoint for reflect-tier LLM (e.g. remote Ollama with larger model). */
35
39
  reflectBaseUrl?: string;
36
40
  reflectApiKey?: string;
@@ -212,6 +216,7 @@ export declare class RateLimitError extends Error {
212
216
  }
213
217
  export declare class LlmClient {
214
218
  private config;
219
+ private ollamaCallCount;
215
220
  constructor(config: LlmConfig);
216
221
  /** Endpoint identity for shared rate-limit state (provider + base URL). */
217
222
  private get endpointKey();
@@ -261,6 +266,16 @@ export declare class LlmClient {
261
266
  * Ollama: use /api/generate with think:false (important for qwen3.5 models).
262
267
  */
263
268
  private completeOllama;
269
+ /**
270
+ * Flush ollama's accumulated memory: unload the model (keep_alive:0) so the
271
+ * runner exits + releases its per-request RSS growth, then wait for the release
272
+ * before the next call reloads fresh. The runner takes >90 s to exit after
273
+ * keep_alive:0 (measured), so the wait is generous (ollamaFlushWaitMs, default
274
+ * 180 s). Logs the flush so the wait is distinguishable from a hang. If the
275
+ * unload request fails (ollama down), the wait is skipped — no dead time for a
276
+ * release that can't have happened. See #229 review.
277
+ */
278
+ private flushOllama;
264
279
  /**
265
280
  * Anthropic Messages API (/v1/messages).
266
281
  * Auth via x-api-key header.
package/dist/llm.js CHANGED
@@ -211,6 +211,12 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
211
211
  if (savedConfig.numCtx !== undefined) {
212
212
  llmConfig.numCtx = (0, config_read_js_1.readPositiveConfig)(savedConfig, "numCtx", 2048);
213
213
  }
214
+ if (savedConfig.ollamaFlushEvery !== undefined) {
215
+ llmConfig.ollamaFlushEvery = (0, config_read_js_1.readNonNegativeConfig)(savedConfig, "ollamaFlushEvery", 0);
216
+ }
217
+ if (savedConfig.ollamaFlushWaitMs !== undefined) {
218
+ llmConfig.ollamaFlushWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "ollamaFlushWaitMs", 180000);
219
+ }
214
220
  }
215
221
  /**
216
222
  * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
@@ -511,6 +517,7 @@ exports.RateLimitError = RateLimitError;
511
517
  const rateLimitedUntilByEndpoint = new Map();
512
518
  class LlmClient {
513
519
  config;
520
+ ollamaCallCount = 0;
514
521
  constructor(config) {
515
522
  this.config = config;
516
523
  }
@@ -546,7 +553,20 @@ class LlmClient {
546
553
  // so ONLY this tier gets the smaller context window — the heavy tiers keep 32768
547
554
  // (preserves detectChunkSize's chunk-sizing). thinking is not threaded (scoring is
548
555
  // excluded from the thinking toggle; ollama is think:false regardless).
549
- return this.complete(this.config.model, prompt, tokens, 600_000, undefined, this.config.numCtx ?? 2048);
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.
561
+ const flushEvery = this.config.ollamaFlushEvery ?? 0;
562
+ if (this.config.provider === "ollama" && flushEvery > 0) {
563
+ this.ollamaCallCount++;
564
+ if (this.ollamaCallCount >= flushEvery) {
565
+ await this.flushOllama(this.config.model);
566
+ this.ollamaCallCount = 0;
567
+ }
568
+ }
569
+ return result;
550
570
  }
551
571
  /**
552
572
  * Reflect-tier completion (nightly reflection, needs reasoning).
@@ -741,6 +761,33 @@ class LlmClient {
741
761
  }
742
762
  return result.trim();
743
763
  }
764
+ /**
765
+ * Flush ollama's accumulated memory: unload the model (keep_alive:0) so the
766
+ * runner exits + releases its per-request RSS growth, then wait for the release
767
+ * before the next call reloads fresh. The runner takes >90 s to exit after
768
+ * keep_alive:0 (measured), so the wait is generous (ollamaFlushWaitMs, default
769
+ * 180 s). Logs the flush so the wait is distinguishable from a hang. If the
770
+ * unload request fails (ollama down), the wait is skipped — no dead time for a
771
+ * release that can't have happened. See #229 review.
772
+ */
773
+ async flushOllama(model) {
774
+ const url = `${this.config.baseUrl.replace(/\/$/, "")}/api/generate`;
775
+ const waitMs = this.config.ollamaFlushWaitMs ?? 180_000;
776
+ console.log(`[hicortex] ollama flush: unloading ${model} after ${this.ollamaCallCount} scoring calls, waiting ${waitMs / 1000}s for memory release…`);
777
+ try {
778
+ await fetch(url, {
779
+ method: "POST",
780
+ headers: { "Content-Type": "application/json" },
781
+ body: JSON.stringify({ model, keep_alive: 0 }),
782
+ signal: AbortSignal.timeout(60_000),
783
+ });
784
+ await new Promise((r) => setTimeout(r, waitMs));
785
+ console.log(`[hicortex] ollama flush: complete`);
786
+ }
787
+ catch {
788
+ console.warn(`[hicortex] ollama flush: unload request failed (ollama unreachable?) — skipping wait`);
789
+ }
790
+ }
744
791
  /**
745
792
  * Anthropic Messages API (/v1/messages).
746
793
  * Auth via x-api-key header.
package/dist/types.d.ts CHANGED
@@ -313,6 +313,27 @@ export interface HicortexConfig {
313
313
  * scoring prompt actually needs more.
314
314
  */
315
315
  numCtx?: number;
316
+ /**
317
+ * Flush ollama's accumulated memory every N scoring calls — workaround for
318
+ * ollama's per-request memory growth (the runner's RSS climbs ~171 MB/call and
319
+ * isn't freed between requests), which swap-thrashes RAM-constrained boxes
320
+ * during long consolidations. Default 0 (off). When >0, every Nth scoring call
321
+ * (`completeFast`) triggers a `keep_alive:0` unload + an `ollamaFlushWaitMs`
322
+ * pause for the runner to exit + release, then the next call reloads fresh.
323
+ * N=15 caps a cycle at ~2.5 GB. Scoped to the fast tier (scoring) only. Note:
324
+ * N counts **logical** scoring calls, not raw HTTP requests — `complete()`
325
+ * retries up to 4× on timeout, so under retry pressure the actual accumulation
326
+ * may be up to 4×N calls' worth. In practice the flush prevents the thrash that
327
+ * causes retries, keeping the count accurate.
328
+ */
329
+ ollamaFlushEvery?: number;
330
+ /**
331
+ * Milliseconds to wait after an ollama flush (`keep_alive:0`) for the runner
332
+ * to exit + release its accumulated memory before the next call reloads.
333
+ * Default 180000 (3 min — the runner takes >90 s to exit after keep_alive:0;
334
+ * doubled for margin). Only relevant when `ollamaFlushEvery` > 0.
335
+ */
336
+ ollamaFlushWaitMs?: number;
316
337
  }
317
338
  /** A config-owned life-sphere domain (see HicortexConfig.domains). */
318
339
  export interface DomainDef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
4
4
  "description": "Self-learning memory for AI agents — experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {