@gamaze/hicortex 0.17.0 → 0.17.2

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/nightly.js CHANGED
@@ -214,6 +214,10 @@ async function runNightly(options = {}) {
214
214
  const dryRun = options.dryRun ?? false;
215
215
  let captureOnly = options.captureOnly ?? false;
216
216
  const watchdog = options.watchdog ?? false;
217
+ const consolidateOnly = options.consolidateOnly ?? false;
218
+ if (captureOnly && consolidateOnly) {
219
+ throw new Error("runNightly: captureOnly and consolidateOnly are mutually exclusive");
220
+ }
217
221
  const stateDir = options.stateDir ?? HICORTEX_HOME;
218
222
  const recaptureWindowDays = options.recaptureWindowDays;
219
223
  rotateNightlyLog(stateDir);
@@ -240,7 +244,9 @@ async function runNightly(options = {}) {
240
244
  // capture-only and falls through to the normal capture path (the capture
241
245
  // lock with waitMs=0 provides single-flight; writeLastRun advances the
242
246
  // cooldown marker on success).
243
- if (watchdog && !dryRun) {
247
+ // consolidateOnly skips the watchdog gate (the watchdog is a capture mechanism;
248
+ // consolidateOnly is the opposite — skip capture, run consolidation only).
249
+ if (watchdog && !dryRun && !consolidateOnly) {
244
250
  captureOnly = true; // the watchdog captures only — never consolidates.
245
251
  // Success-cooldown: lastNightly is advanced ONLY on a clean capture
246
252
  // (writeLastRun is success-gated), so reusing it gives SUCCESS-based
@@ -280,9 +286,9 @@ async function runNightly(options = {}) {
280
286
  console.log(`[hicortex] watchdog: cooldown elapsed + ${target} reachable — capturing`);
281
287
  }
282
288
  if (savedConfig?.mode === "client") {
283
- // --capture-only is accepted in client mode but irrelevant: client nightly
284
- // is already capture-only (no consolidation step). (If watchdog-gated, the
285
- // gate above already ran.)
289
+ // --capture-only and --consolidate-only are both accepted in client mode but
290
+ // irrelevant: client nightly is already capture-only (no consolidation step),
291
+ // and consolidation-only makes no sense without a local DB.
286
292
  await runClientNightly(savedConfig, dryRun, stateDir, recaptureWindowDays, watchdog);
287
293
  return;
288
294
  }
@@ -292,7 +298,7 @@ async function runNightly(options = {}) {
292
298
  (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
293
299
  (0, retrieval_js_1.configureRecall)(savedConfig);
294
300
  (0, retrieval_js_1.configureScoring)(savedConfig);
295
- const modeLabel = captureOnly ? " (capture-only)" : dryRun ? " (dry run)" : "";
301
+ const modeLabel = consolidateOnly ? " (consolidate-only)" : captureOnly ? " (capture-only)" : dryRun ? " (dry run)" : "";
296
302
  console.log(`[hicortex] Nightly pipeline starting${modeLabel}`);
297
303
  if (captureOnly) {
298
304
  console.log(`[hicortex] capture-only run — consolidation skipped`);
@@ -326,136 +332,248 @@ async function runNightly(options = {}) {
326
332
  let batches = [];
327
333
  let memoriesIngested = 0;
328
334
  let hadTransientFailure = false;
329
- // Full nightly waits out a transient --capture-only overlap (each segment
330
- // POST can block up to 20 min); capture-only fails fast. dry-run writes
331
- // nothing so it needs no lock.
332
- const lockWaitMs = captureLockWaitMs();
333
- const releaseLock = dryRun
334
- ? (() => { })
335
- : await (0, capture_js_1.acquireCaptureLock)(stateDir, captureOnly ? 0 : lockWaitMs);
336
- if (!releaseLock) {
337
- if (captureOnly) {
338
- console.warn("[hicortex] Another capture run holds the lock — skipping this capture-only run.");
339
- return;
340
- }
341
- // Full nightly couldn't get the lock even after waiting: skip capture and
342
- // hold the watermark, but STILL run consolidation + telemetry below so an
343
- // overlapping capture-only run never silently starves consolidation (fix 10).
344
- console.warn(`[hicortex] Capture lock still held after waiting ${Math.round(lockWaitMs / 60000)} min — ` +
345
- `skipping capture this run (watermark held), consolidation still runs.`);
346
- hadTransientFailure = true;
335
+ // consolidateOnly (hosted service): skip capture entirely. The hosted
336
+ // consolidation timer uses this so per-tenant nightly runs don't ingest the
337
+ // operator's local sessions into the tenant's DB.
338
+ if (consolidateOnly) {
339
+ console.log("[hicortex] consolidate-only run — capture skipped");
347
340
  }
348
341
  else {
349
- try {
350
- // Step 1: Read new transcripts (CC + Hermes + Pi + OpenClaw). Discovery
351
- // is whole-session by mtime/ended_at; per-session cursors slice each
352
- // discovered session down to its unseen delta (#189).
353
- const since = computeSince(stateDir, recaptureWindowDays);
354
- if (recaptureWindowDays) {
355
- console.log(`[hicortex] --recapture-window ${recaptureWindowDays}d: reading transcripts since ${since.toISOString()}`);
342
+ // Full nightly waits out a transient --capture-only overlap (each segment
343
+ // POST can block up to 20 min); capture-only fails fast. dry-run writes
344
+ // nothing so it needs no lock.
345
+ const lockWaitMs = captureLockWaitMs();
346
+ const releaseLock = dryRun
347
+ ? (() => { })
348
+ : await (0, capture_js_1.acquireCaptureLock)(stateDir, captureOnly ? 0 : lockWaitMs);
349
+ if (!releaseLock) {
350
+ if (captureOnly) {
351
+ console.warn("[hicortex] Another capture run holds the lock — skipping this capture-only run.");
352
+ return;
353
+ }
354
+ // Full nightly couldn't get the lock even after waiting: skip capture and
355
+ // hold the watermark, but STILL run consolidation + telemetry below so an
356
+ // overlapping capture-only run never silently starves consolidation (fix 10).
357
+ console.warn(`[hicortex] Capture lock still held after waiting ${Math.round(lockWaitMs / 60000)} min — ` +
358
+ `skipping capture this run (watermark held), consolidation still runs.`);
359
+ hadTransientFailure = true;
360
+ }
361
+ else {
362
+ try {
363
+ // Step 1: Read new transcripts (CC + Hermes + Pi + OpenClaw). Discovery
364
+ // is whole-session by mtime/ended_at; per-session cursors slice each
365
+ // discovered session down to its unseen delta (#189).
366
+ const since = computeSince(stateDir, recaptureWindowDays);
367
+ if (recaptureWindowDays) {
368
+ console.log(`[hicortex] --recapture-window ${recaptureWindowDays}d: reading transcripts since ${since.toISOString()}`);
369
+ }
370
+ else {
371
+ console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
372
+ }
373
+ const cursorStore = (0, capture_cursors_js_1.openCursorStore)(stateDir);
374
+ const cursorMap = cursorStore.map();
375
+ ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since, undefined, cursorMap);
376
+ hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
377
+ // Pi is a supported harness (readPiTranscripts no-ops when
378
+ // ~/.pi/agent/sessions is absent). Retired only on specific deployments
379
+ // by simply having no Pi session files — not removed from the pipeline.
380
+ piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
381
+ // OpenClaw persists sessions in the Pi v3 format at ~/.openclaw/agents/;
382
+ // no-ops when OC isn't installed.
383
+ ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
384
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
385
+ if (ccBatches.length > 0)
386
+ console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
387
+ if (hermesBatches.length > 0)
388
+ console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
389
+ if (piBatches.length > 0)
390
+ console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
391
+ if (ocBatches.length > 0)
392
+ console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
393
+ console.log(`[hicortex] Total: ${batches.length} new session(s)`);
394
+ if (batches.length === 0 && !dryRun) {
395
+ console.log(captureOnly
396
+ ? `[hicortex] No new transcripts. Nothing to capture.`
397
+ : `[hicortex] No new transcripts. Running consolidation only.`);
398
+ }
399
+ // Step 2: pack each session's delta into ≤60K segments and POST to the
400
+ // local daemon via /distill; cursors advance on confirmed success.
401
+ // source_agent_id / source_domain are per-client provenance from
402
+ // config.json (agentId / sourceDomain) — attribution only, no filtering.
403
+ const result = await (0, capture_js_1.captureBatches)(batches, {
404
+ post: makeLocalPost(port),
405
+ cursorStore,
406
+ dryRun,
407
+ sourceAgentId: savedConfig?.agentId,
408
+ sourceDomain: savedConfig?.sourceDomain,
409
+ });
410
+ memoriesIngested = result.memoriesIngested;
411
+ // A 429/401 stop must hold the watermark too (fix 1): the loop abandoned
412
+ // the remaining sessions, and mtime discovery would never re-find them.
413
+ hadTransientFailure = result.hadTransientFailure || result.stopped !== undefined;
356
414
  }
357
- else {
358
- console.log(`[hicortex] Reading transcripts since ${since.toISOString()}`);
415
+ finally {
416
+ releaseLock();
359
417
  }
360
- const cursorStore = (0, capture_cursors_js_1.openCursorStore)(stateDir);
361
- const cursorMap = cursorStore.map();
362
- ccBatches = (0, transcript_reader_js_1.readCcTranscripts)(since, undefined, cursorMap);
363
- hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
364
- // Pi is a supported harness (readPiTranscripts no-ops when
365
- // ~/.pi/agent/sessions is absent). Retired only on specific deployments
366
- // by simply having no Pi session files — not removed from the pipeline.
367
- piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
368
- // OpenClaw persists sessions in the Pi v3 format at ~/.openclaw/agents/;
369
- // no-ops when OC isn't installed.
370
- ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
371
- batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
372
- if (ccBatches.length > 0)
373
- console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
374
- if (hermesBatches.length > 0)
375
- console.log(`[hicortex] Found ${hermesBatches.length} Hermes session(s)`);
376
- if (piBatches.length > 0)
377
- console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
378
- if (ocBatches.length > 0)
379
- console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
380
- console.log(`[hicortex] Total: ${batches.length} new session(s)`);
381
- if (batches.length === 0 && !dryRun) {
382
- console.log(captureOnly
383
- ? `[hicortex] No new transcripts. Nothing to capture.`
384
- : `[hicortex] No new transcripts. Running consolidation only.`);
418
+ console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
419
+ // Prune aged-out cursors (90d) — only on a clean run so a transient
420
+ // failure doesn't drop a still-needed cursor.
421
+ if (!dryRun && !hadTransientFailure) {
422
+ const pruned = (0, capture_cursors_js_1.pruneCursors)(stateDir);
423
+ if (pruned > 0)
424
+ console.log(`[hicortex] Pruned ${pruned} stale capture cursor(s)`);
385
425
  }
386
- // Step 2: pack each session's delta into ≤60K segments and POST to the
387
- // local daemon via /distill; cursors advance on confirmed success.
388
- // source_agent_id / source_domain are per-client provenance from
389
- // config.json (agentId / sourceDomain) — attribution only, no filtering.
390
- const result = await (0, capture_js_1.captureBatches)(batches, {
391
- post: makeLocalPost(port),
392
- cursorStore,
393
- dryRun,
394
- sourceAgentId: savedConfig?.agentId,
395
- sourceDomain: savedConfig?.sourceDomain,
396
- });
397
- memoriesIngested = result.memoriesIngested;
398
- // A 429/401 stop must hold the watermark too (fix 1): the loop abandoned
399
- // the remaining sessions, and mtime discovery would never re-find them.
400
- hadTransientFailure = result.hadTransientFailure || result.stopped !== undefined;
401
- }
402
- finally {
403
- releaseLock();
404
- }
405
- console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
406
- // Prune aged-out cursors (90d) — only on a clean run so a transient
407
- // failure doesn't drop a still-needed cursor.
408
- if (!dryRun && !hadTransientFailure) {
409
- const pruned = (0, capture_cursors_js_1.pruneCursors)(stateDir);
410
- if (pruned > 0)
411
- console.log(`[hicortex] Pruned ${pruned} stale capture cursor(s)`);
412
426
  }
413
- }
427
+ } // end capture block (consolidateOnly else)
414
428
  // Step 3: Consolidation — skipped in capture-only mode, dry-run, or no LLM.
415
429
  // Runs even if capture had transient failures (opens DB directly, independent
416
430
  // of the HTTP capture path). Full nightly only — capture-only runs are
417
431
  // intended to run more frequently than once daily.
418
432
  let lessonsGenerated; // hoisted for telemetry; undefined when reflection didn't run (skipped) — bucketed apart from a real 0
433
+ // #245: memories evicted by the capacity stage (hoisted for the dashboard
434
+ // snapshot). 0 is a real value (under cap), so this stays undefined only
435
+ // when consolidation didn't run at all (capture-only / no_llm / skipped).
436
+ let evictedCount;
437
+ // Resolved cap (#245) for the dashboard snapshot. Hoisted so the snapshot
438
+ // writer (outside the consolidation block) can stamp `capacity` even when
439
+ // consolidation was skipped (the cap is still "in force" config-wise).
440
+ const memorySoftCapResolved = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "memorySoftCap", consolidate_js_1.DEFAULT_MEMORY_SOFT_CAP);
419
441
  // Consolidation outcome for telemetry (0.17). undefined on capture-only runs
420
442
  // (which send no nightly ping). "skipped" = runConsolidation's built-in
421
443
  // nothing-to-do short-circuit (zero LLM calls), NOT a failure.
444
+ // "throttled" (#246) = the llmTokensPerMonth fair-use cap was projected to
445
+ // be exceeded, so consolidation was skipped before any LLM call.
422
446
  let consolidationStatus;
447
+ // #246: total consolidation tokens consumed this run (hoisted for telemetry
448
+ // + the dashboard snapshot). Undefined when consolidation didn't run at all
449
+ // (capture-only / no_llm / throttled) so the optional field is omitted.
450
+ let tokensThisRun;
451
+ // #246: per-stage token breakdown (hoisted for the dashboard snapshot).
452
+ let tokensByStage;
423
453
  if (!dryRun && !captureOnly) {
424
454
  if (!llm || !llmConfig) {
425
455
  console.error("[hicortex] consolidation skipped: no LLM configured — run npx @gamaze/hicortex init");
426
456
  consolidationStatus = "no_llm";
427
457
  }
428
458
  else {
429
- // One model serves all phases (#231) there is no separate endpoint to
430
- // pre-flight. If the model doesn't answer, `complete()` already retries at
431
- // 30s/60s/120s (~3.5 min); anything still failing after that is an outage,
432
- // not a blip. A failed phase costs latency, not data: capture cursors hold
433
- // on failure (dup-over-loss), and consolidation has resumable cursors
434
- // (domainCursor, supersessionCursor). The nightly runs 2-4×/day, so the
435
- // wait is hours no polling, no new config. (Issue #231.)
436
- const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
437
- console.log(`[hicortex] Running consolidation...`);
438
- const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, false, undefined, {
439
- domains: cfgDomains,
440
- contentDomainsReady: true,
441
- weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
442
- }, {
443
- minSimilarity: savedConfig?.supersessionMinSimilarity,
444
- maxCalls: savedConfig?.supersessionMaxCalls,
445
- },
446
- // #241: config-driven total LLM-call ceiling (default 5000, was 200).
447
- (0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS));
448
- console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
449
- (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
450
- consolidationStatus = report.status;
451
- // Only set when reflection actually RAN (not skipped). A skipped stage
452
- // (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
453
- // would make "endpoint down" indistinguishable from "prompt too tight"
454
- // in the fleet aggregate. Leave undefined so the optional field is
455
- // omitted and the aggregate buckets skipped runs separately.
456
- const refl = report.stages.reflection;
457
- if (refl && !refl.skipped)
458
- lessonsGenerated = refl.lessons_generated;
459
+ // #246: fair-use cap. Default 0 = unlimited (self-hosted default
460
+ // never throttles). When > 0, project this run's cost against the
461
+ // period running total. The estimate = the PREVIOUS run's actual usage
462
+ // (state.llmTokensLastRun, 0/absent on the first metered run = never
463
+ // throttle the first run, since there's no baseline yet). Conservative:
464
+ // over-throttle vs over-spend, since a throttled night just defers work
465
+ // to the next period (the corpus is not lost consolidation has
466
+ // resumable cursors and runs 2-4×/day on the 0.17 timer).
467
+ const tokenCap = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "llmTokensPerMonth", 0);
468
+ if (tokenCap > 0) {
469
+ // Pure decision lives in consolidate.ts (shouldThrottleTokens) so it
470
+ // can be unit-tested without spinning up the nightly. Monthly reset
471
+ // + last-run estimate are handled inside the helper.
472
+ const periodState = (0, state_js_1.loadState)(stateDir).llmTokensThisPeriod;
473
+ const lastRunTokens = (0, state_js_1.loadState)(stateDir).llmTokensLastRun ?? 0;
474
+ const decision = (0, consolidate_js_1.shouldThrottleTokens)(tokenCap, periodState, lastRunTokens);
475
+ if (decision.throttle) {
476
+ console.warn(`[hicortex] Consolidation throttled: token fair-use cap reached ` +
477
+ `(${decision.used.toLocaleString()} used + ~${lastRunTokens.toLocaleString()} estimated / ${tokenCap.toLocaleString()} this period).`);
478
+ consolidationStatus = "throttled";
479
+ // Month-boundary reset even when throttled (#246 CR): without this,
480
+ // llmTokensLastRun stays stale from the prior month → soft-locks
481
+ // every subsequent run forever. Reset the period + the last-run
482
+ // estimate so the next month starts clean.
483
+ if (!dryRun) {
484
+ (0, state_js_1.updateState)((s) => {
485
+ const now = new Date();
486
+ const cur = s.llmTokensThisPeriod;
487
+ const startD = cur?.periodStart ? new Date(cur.periodStart) : now;
488
+ if (startD.getUTCFullYear() !== now.getUTCFullYear() ||
489
+ startD.getUTCMonth() !== now.getUTCMonth()) {
490
+ s.llmTokensThisPeriod = { prompt: 0, completion: 0, total: 0, periodStart: now.toISOString() };
491
+ s.llmTokensLastRun = 0;
492
+ console.log("[hicortex] Token fair-use period reset (new month) — throttle cleared.");
493
+ }
494
+ }, stateDir);
495
+ }
496
+ }
497
+ }
498
+ if (consolidationStatus !== "throttled") {
499
+ // One model serves all phases (#231) — there is no separate endpoint to
500
+ // pre-flight. If the model doesn't answer, `complete()` already retries at
501
+ // 30s/60s/120s (~3.5 min); anything still failing after that is an outage,
502
+ // not a blip. A failed phase costs latency, not data: capture cursors hold
503
+ // on failure (dup-over-loss), and consolidation has resumable cursors
504
+ // (domainCursor, supersessionCursor). The nightly runs 2-4×/day, so the
505
+ // wait is hours — no polling, no new config. (Issue #231.)
506
+ const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
507
+ console.log(`[hicortex] Running consolidation...`);
508
+ const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, false, undefined, {
509
+ domains: cfgDomains,
510
+ contentDomainsReady: true,
511
+ weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
512
+ }, {
513
+ minSimilarity: savedConfig?.supersessionMinSimilarity,
514
+ maxCalls: savedConfig?.supersessionMaxCalls,
515
+ },
516
+ // #241: config-driven total LLM-call ceiling (default 5000, was 200).
517
+ (0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS),
518
+ // #245: soft cap on the corpus (default 10000; 0 disables eviction).
519
+ memorySoftCapResolved);
520
+ console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
521
+ (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
522
+ consolidationStatus = report.status;
523
+ // #245: capture the eviction count for the dashboard snapshot. The
524
+ // stage always returns `evicted` (0 when under cap / disabled); report
525
+ // it as 0 (a real value), not undefined, when the stage ran.
526
+ evictedCount = report.stages.memory_cap?.evicted ?? 0;
527
+ // Only set when reflection actually RAN (not skipped). A skipped stage
528
+ // (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
529
+ // would make "endpoint down" indistinguishable from "prompt too tight"
530
+ // in the fleet aggregate. Leave undefined so the optional field is
531
+ // omitted and the aggregate buckets skipped runs separately.
532
+ const refl = report.stages.reflection;
533
+ if (refl && !refl.skipped)
534
+ lessonsGenerated = refl.lessons_generated;
535
+ // #246: surface token totals for telemetry + dashboard snapshot. Both
536
+ // fields stay undefined on a skipped/failed run (no metered calls →
537
+ // nothing to report; the optional fields are omitted from the ping).
538
+ const tokensTotal = report.budget?.tokens_total;
539
+ if (tokensTotal && tokensTotal.total > 0) {
540
+ tokensThisRun = tokensTotal.total;
541
+ tokensByStage = report.budget?.tokens_by_stage;
542
+ }
543
+ // #246: accrue to state.json (monthly reset + last-run estimate for
544
+ // the next throttle check). Written even on a failed run — a partial
545
+ // run that made metered calls before the failure still spent tokens,
546
+ // and the next run's estimate should reflect that.
547
+ if (!dryRun) {
548
+ (0, state_js_1.updateState)((s) => {
549
+ const now = new Date();
550
+ const cur = s.llmTokensThisPeriod;
551
+ let periodStart = cur?.periodStart ?? now.toISOString();
552
+ let prompt = cur?.prompt ?? 0;
553
+ let completion = cur?.completion ?? 0;
554
+ let total = cur?.total ?? 0;
555
+ // Monthly reset: if periodStart is in a previous calendar month,
556
+ // zero the accrual before adding this run's contribution.
557
+ const startD = new Date(periodStart);
558
+ if (startD.getUTCFullYear() !== now.getUTCFullYear() ||
559
+ startD.getUTCMonth() !== now.getUTCMonth()) {
560
+ periodStart = now.toISOString();
561
+ prompt = 0;
562
+ completion = 0;
563
+ total = 0;
564
+ }
565
+ if (tokensTotal) {
566
+ prompt += tokensTotal.prompt;
567
+ completion += tokensTotal.completion;
568
+ total += tokensTotal.total;
569
+ }
570
+ s.llmTokensThisPeriod = {
571
+ prompt, completion, total, periodStart,
572
+ };
573
+ s.llmTokensLastRun = tokensTotal?.total ?? 0;
574
+ }, stateDir);
575
+ }
576
+ }
459
577
  }
460
578
  }
461
579
  // Step 4: Update last-run timestamp.
@@ -510,7 +628,12 @@ async function runNightly(options = {}) {
510
628
  lessonsGenerated,
511
629
  dedup,
512
630
  supersession,
513
- });
631
+ evicted: evictedCount,
632
+ // #246: token accounting from this run's consolidation (undefined
633
+ // when consolidation didn't run or made no metered calls).
634
+ tokensThisRun,
635
+ tokensByStage,
636
+ }, memorySoftCapResolved);
514
637
  }
515
638
  catch (snapErr) {
516
639
  // The snapshot is a monitoring side-effect — a failure here must NOT
@@ -549,6 +672,9 @@ async function runNightly(options = {}) {
549
672
  lessons: storage.getLessons(db, 365).length,
550
673
  lessonsGenerated,
551
674
  consolidation: consolidationStatus,
675
+ // #246: total tokens consumed by this run's consolidation (absent on
676
+ // capture-only / throttled / no_llm / skipped — no metered calls).
677
+ tokens_this_run: tokensThisRun,
552
678
  sessions: batches.length,
553
679
  ok: !hadTransientFailure,
554
680
  shown: adoption.shown,
@@ -574,12 +700,13 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
574
700
  // the whole run — the pre-flight only needs the link back, which can take
575
701
  // ~1 min after wake.
576
702
  //
577
- // Config-overridable (#163): a wired Pi vs a sleeping/roaming laptop want
578
- // different values. Defaults: 20s per-attempt timeout, 3 attempts, 60s gap.
579
- // The 20s per-attempt (bumped from 15s in 0.17) absorbs a cold Tailscale
580
- // handshake to a home server (DERP relay + NAT traversal can take 5–30s on a
581
- // roaming laptop). For a genuinely DOWN link (`fetch failed`) no timeout
582
- // length helps the capture watchdog's frequent retry handles that (#239).
703
+ // Config-overridable (#163): a wired/well-connected client vs one whose link
704
+ // is slow to re-establish after wake want different values. Defaults: 20s
705
+ // per-attempt timeout, 3 attempts, 60s gap. The 20s per-attempt (bumped from
706
+ // 15s in 0.17) absorbs a slow link coming back after the client wakes a
707
+ // remote server reached over a mesh/VPN link can take several seconds to
708
+ // answer on the first request. For a genuinely DOWN link (`fetch failed`) no
709
+ // timeout length helps — the capture watchdog's frequent retry handles that (#239).
583
710
  //
584
711
  // WALL-CLOCK NOTE: setTimeout and AbortSignal.timeout do NOT advance while
585
712
  // macOS is asleep, so the ~3m worst case (3×20s + 2×60s) is wall-clock-
package/dist/state.d.ts CHANGED
@@ -60,6 +60,28 @@ export interface HicortexState {
60
60
  * gradually over many nights.
61
61
  */
62
62
  supersessionCursor?: number;
63
+ /**
64
+ * LLM token usage accrued this billing period (#246). Period reset is
65
+ * monthly: when `periodStart` is in a previous calendar month, the totals
66
+ * reset to 0 + a new periodStart (handled in nightly.ts after each run).
67
+ * The fair-use cap (`config.llmTokensPerMonth`) consults `.total` before
68
+ * each consolidation to throttle when over budget. Absent = no usage
69
+ * recorded yet (treated as 0 by the throttle).
70
+ */
71
+ llmTokensThisPeriod?: {
72
+ prompt: number;
73
+ completion: number;
74
+ total: number;
75
+ /** ISO timestamp of the start of the current accrual period. */
76
+ periodStart: string;
77
+ };
78
+ /**
79
+ * Total tokens consumed by the previous nightly's consolidation (#246).
80
+ * Used as the ESTIMATE for the next run's fair-use check (this-period total
81
+ * + last-run total > cap → throttle). 0/absent on the first metered run,
82
+ * which means the first run is never throttled (correct: no baseline yet).
83
+ */
84
+ llmTokensLastRun?: number;
63
85
  }
64
86
  /**
65
87
  * Load the state file. Returns an empty state if the file is missing
@@ -89,13 +89,23 @@ export interface TelemetryPayload {
89
89
  * Consolidation outcome for THIS full nightly (server mode only —
90
90
  * capture-only runs send no nightly ping, so the field is absent there).
91
91
  * `runConsolidation`'s status: "completed" | "skipped" | "failed", plus
92
- * "no_llm" when consolidation was skipped because no LLM was configured.
92
+ * "no_llm" when consolidation was skipped because no LLM was configured, and
93
+ * "throttled" (#246) when the run was skipped because the
94
+ * `llmTokensPerMonth` fair-use cap was projected to be exceeded.
93
95
  * "skipped" = the built-in nothing-to-do short-circuit (no new + no unscored
94
96
  * memories → zero LLM calls), NOT a failure. Lets the fleet aggregate tell a
95
97
  * real consolidation run from a no-op without repurposing `ok` (which is the
96
98
  * capture-health signal). 0.17+.
97
99
  */
98
- consolidation?: "completed" | "skipped" | "failed" | "no_llm";
100
+ consolidation?: "completed" | "skipped" | "failed" | "no_llm" | "throttled";
101
+ /**
102
+ * Total LLM tokens consumed by THIS nightly's consolidation (#246) — the
103
+ * BudgetTracker total. Server-mode only (capture-only + client runs make no
104
+ * consolidation LLM calls). Absent on a throttled/no-LLM/skipped run (no
105
+ * calls → nothing to meter). Aggregate-only: no per-stage breakdown on the
106
+ * wire (that lives in the dashboard snapshot, not the telemetry ping).
107
+ */
108
+ tokens_this_run?: number;
99
109
  }
100
110
  /**
101
111
  * Check if telemetry is enabled. Disabled by:
package/dist/types.d.ts CHANGED
@@ -146,12 +146,38 @@ export interface ConsolidationReport {
146
146
  pruned: number;
147
147
  failed: number;
148
148
  };
149
+ /** Capacity eviction (#245) — runs after decay_prune. When the corpus
150
+ * exceeds `memorySoftCap`, the lowest-effectiveStrength memories are
151
+ * evicted until under the cap. `cap = 0` (disabled) → evicted = 0. */
152
+ memory_cap?: {
153
+ /** The configured cap (always reported, including 0 = disabled). */
154
+ cap: number;
155
+ /** Memories deleted this run (0 when under cap, dry-run, or disabled). */
156
+ evicted: number;
157
+ };
149
158
  };
150
159
  budget?: {
151
160
  max_calls: number;
152
161
  calls_used: number;
153
162
  calls_remaining: number;
154
163
  calls_by_stage: Record<string, number>;
164
+ /**
165
+ * Token usage per stage (#246). Each value sums prompt + completion +
166
+ * total across every metered LLM call in that stage this run. A stage with
167
+ * no metered calls (claude-cli path, or stage didn't run) is absent — the
168
+ * dashboard treats absent as "no signal", distinct from zero.
169
+ */
170
+ tokens_by_stage?: Record<string, {
171
+ prompt: number;
172
+ completion: number;
173
+ total: number;
174
+ }>;
175
+ /** Run-wide token totals (#246) — sum of every recordUsage call this run. */
176
+ tokens_total?: {
177
+ prompt: number;
178
+ completion: number;
179
+ total: number;
180
+ };
155
181
  };
156
182
  }
157
183
  /** Plugin configuration from openclaw.plugin.json configSchema. */
@@ -244,6 +270,15 @@ export interface HicortexConfig {
244
270
  * classification backlog and drained large backlogs at ~cap/night).
245
271
  */
246
272
  consolidateMaxLlmCalls?: number;
273
+ /**
274
+ * Release channel pinned into the generated daemon/timer ExecStart for
275
+ * **npx-thin** installs (global-binary installs use the absolute binary path
276
+ * and are unaffected). E.g. `"rc"` → the timer runs
277
+ * `npx -y @gamaze/hicortex@rc nightly`, so the host tracks the rc dist-tag
278
+ * (the internal fleet uses this to ride rc through a pre-promotion soak).
279
+ * Absent → auto-detect (bare on `latest`, else `@next`). (0.17.1)
280
+ */
281
+ updateChannel?: string;
247
282
  /**
248
283
  * Minimum cosine(memory embedding, best domain prototype) for a no-fit
249
284
  * memory to earn a WEAK primary instead of decaying (see nofit.ts).
@@ -336,6 +371,32 @@ export interface HicortexConfig {
336
371
  * leaner system prompts.
337
372
  */
338
373
  lessonsLimit?: number;
374
+ /**
375
+ * Soft cap on the memory corpus (default 10000). When the corpus exceeds this,
376
+ * the nightly's capacity-eviction stage (#245) removes the lowest-
377
+ * `effectiveStrength` memories (ties broken by oldest access) until under the
378
+ * cap. `0` = disabled (indefinite growth — the pre-#245 behaviour). This is
379
+ * the active forgetting mechanism that replaces the inert time-based prune
380
+ * (`effectiveStrength < 0.01` in stageDecayPrune, which essentially never
381
+ * fires given the strength floor + 365-day half-life). At 10K memories the
382
+ * load + JS sort is <100 ms. The evicted tail is cold by construction
383
+ * (effectiveStrength is the same decay-weighted score used in recall ranking,
384
+ * so these were not surfacing in the top-k anyway).
385
+ */
386
+ memorySoftCap?: number;
387
+ /**
388
+ * Monthly fair-use ceiling on consolidation LLM token consumption (#246).
389
+ * Default `0` = unlimited (the self-hosted default — no cap, never throttled).
390
+ * When > 0: before each consolidation run, the nightly checks
391
+ * `llmTokensThisPeriod.total + llmTokensLastRun > llmTokensPerMonth`; if so,
392
+ * consolidation is skipped and telemetry reports `consolidation: "throttled"`.
393
+ * The estimate uses the previous run's actual usage as a proxy — conservative
394
+ * (over-throttle vs over-spend) since a throttled night just defers work to
395
+ * the next period. The hosted service sets this per-tenant to defend against
396
+ * noisy neighbors; a self-hosted user on a free local model has no reason to
397
+ * set it. Period resets monthly (state.json `llmTokensThisPeriod.periodStart`).
398
+ */
399
+ llmTokensPerMonth?: number;
339
400
  }
340
401
  /** A config-owned life-sphere domain (see HicortexConfig.domains). */
341
402
  export interface DomainDef {
@@ -89,7 +89,7 @@ Env overrides: `HICORTEX_URL`, `HICORTEX_AUTH_TOKEN`.
89
89
  ## Topology
90
90
 
91
91
  - **Server host:** runs Hicortex. Set `hicortex_url: http://localhost:8787` (localhost bypasses auth).
92
- - **Other Hermes boxes:** set `hicortex_url` to the server's Tailscale hostname (e.g. `http://memory-server:8787`) and `HICORTEX_AUTH_TOKEN` to the server's token. Each box recalls from the same shared brain.
92
+ - **Other Hermes boxes:** set `hicortex_url` to the server's hostname (e.g. `http://memory-server:8787`) and `HICORTEX_AUTH_TOKEN` to the server's token. Each box recalls from the same shared brain.
93
93
 
94
94
  ## Notes
95
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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": {