@gamaze/hicortex 0.17.1 → 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/README.md +2 -1
- package/assets/dashboard.html +79 -0
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +10 -3
- package/dist/consolidate.d.ts +67 -1
- package/dist/consolidate.js +198 -16
- package/dist/dashboard.d.ts +71 -2
- package/dist/dashboard.js +36 -1
- package/dist/distiller.js +1 -1
- package/dist/domain-classify.d.ts +8 -2
- package/dist/domain-classify.js +19 -5
- package/dist/init.js +1 -1
- package/dist/llm.d.ts +52 -4
- package/dist/llm.js +65 -5
- package/dist/nightly.d.ts +7 -0
- package/dist/nightly.js +248 -121
- package/dist/state.d.ts +22 -0
- package/dist/telemetry.d.ts +12 -2
- package/dist/types.d.ts +52 -0
- package/hermes-plugin/hicortex/README.md +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
284
|
-
// is already capture-only (no consolidation step)
|
|
285
|
-
//
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
415
|
+
finally {
|
|
416
|
+
releaseLock();
|
|
359
417
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
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
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -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. */
|
|
@@ -345,6 +371,32 @@ export interface HicortexConfig {
|
|
|
345
371
|
* leaner system prompts.
|
|
346
372
|
*/
|
|
347
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;
|
|
348
400
|
}
|
|
349
401
|
/** A config-owned life-sphere domain (see HicortexConfig.domains). */
|
|
350
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
|
|
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.
|
|
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": {
|