The closed loop has two halves: the SkillStore authors procedural memory, and the curator disposes of what goes unused. UsageStore is a telemetry sidecar that counts skill use/view/patch; runCurator is a deterministic pass that moves long-idle agent skills active → stale → archived. Two invariants define it: never delete (archive is the terminal state), and TIME is an injected Clock — never Date.now(). A CLONE of Hermes' skill_usage.py + curator.py.
The decision is a pure function over an inactivity anchor and two cutoffs. The branch order — archive first, then stale, then reactivate — mirrors apply_automatic_transitions exactly:
// packages/hermes/src/curator/curator.ts:123-136 — nextState const nextState = (record, staleCutoff, archiveCutoff): SkillState => { const anchor = record.lastActivityAt > 0 ? record.lastActivityAt : Number.POSITIVE_INFINITY; // never-active ⇒ never old const current = record.state; if (anchor <= archiveCutoff && current !== 'archived') return 'archived'; if (anchor <= staleCutoff && current === 'active') return 'stale'; if (anchor > staleCutoff && current === 'stale') return 'active'; // reactivate return current; };
A brand-new skill has lastActivityAt = 0 (creation is not activity). If 0 were used literally as the anchor, it would be ≤ every cutoff and the skill would archive on its first pass. So the code anchors a never-active record to Number.POSITIVE_INFINITY — newer than any cutoff — guaranteeing it is neither stale nor archived until it has actually been used and gone idle. The test "never archives a brand-new, never-active skill (lastActivityAt = 0)" proves it. (The header comment phrases this as "treated as now"; the implementation uses +Infinity, which has the same effect and is order-independent.)
Why archive-first? An active skill that's been idle past the archive cutoff should land in archived directly — skipping stale — so the archive branch must win before the stale branch. The test "jumps active → archived directly when old enough (skips stale)" guards that order.
Before any transition, runCurator applies two orthogonal opt-outs. Only agent-authored skills are curator-managed, and a pinned skill is exempt on every path:
// packages/hermes/src/curator/curator.ts:86-108 (condensed) for (const name of names) { const record = sidecar[name]; if (record.createdBy !== 'agent') { skipped.push({ name, reason: 'not-agent-created' }); continue; } if (record.pinned) { skipped.push({ name, reason: 'pinned' }); continue; } const to = nextState(record, staleCutoff, archiveCutoff); if (to === record.state) { skipped.push({ name, reason: 'no-change' }); continue; } const persisted = await deps.usage.put(name, { ...record, state: to }); if (!persisted.ok) return persisted; // fail-closed: don't report unsaved work transitioned.push({ name, from: record.state, to }); }
Skips are reported, not hidden — the CuratorReport carries both transitioned[] and skipped[] (each with a typed reason: pinned/not-agent-created/no-change), both in stable name order. And persistence is per-transition: if a put fails, the pass aborts with that err rather than reporting a state change that didn't durably happen.
UsageStore records events by bumping a counter and stamping lastActivityAt = clock(). It carries the same read/write asymmetry as the memory store — a corrupt sidecar reads as empty (never breaks a hot path), but a failed write surfaces as err:
// packages/hermes/src/curator/usage-store.ts:153-166 — best-effort load private async load(): Promise<UsageSidecar> { const stat = await tryCatchAsync(() => this.fs.stat(this.sidecarPath)); if (!stat.ok || !stat.value) return {}; // missing ⇒ empty const read = await tryCatchAsync(() => this.fs.readText(this.sidecarPath)); if (!read.ok) return {}; // IO error ⇒ empty const parsed = tryParseJson(read.value); if (!parsed.ok) return {}; // bad JSON ⇒ empty const valid = usageSidecarSchema.safeParse(parsed.value); if (!valid.success) return {}; // wrong shape ⇒ empty return valid.data; }
Clock is injected into the UsageStore and into runCurator (curator.ts:53-59 names this explicitly). So "an event recorded now" and "a transition decided now" agree — there's no skew between when activity was stamped and when staleness is judged. The sidecar is serialized with sorted keys + 2-space indent (mirroring Python's json.dump(..., sort_keys=True, indent=2)) so writes are byte-stable and diff-friendly.createdBy: 'user' and hasn't been touched in a year. What does runCurator do?not-agent-created. (And nothing is ever deleted — archive is the terminal state.)lastActivityAt = 0. On the first curator pass it is…lastActivityAt stays 0; nextState anchors that to Number.POSITIVE_INFINITY so the skill is neither stale nor archived until it has genuinely been used and then gone idle.nextState (archive → stale → reactivate) load-bearing?active skill would only step to stale this pass instead of jumping to archived. The "jumps active → archived directly" test guards the order, mirroring apply_automatic_transitions..archive/ directory move is an out-of-scope transport concern. A reactivation path even brings a stale skill back to active if it's used again before the archive cutoff.err.