Course / Lesson 9  ·  PT-BR
Lesson 09 · Deep dive · subsystem 3 of 7

UsageStore + runCurator — the disposal half

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 lifecycle

active created here stale idle ≥ 30d archived idle ≥ 90d · terminal stale cutoff archive cutoff used again ⇒ reactivate old enough ⇒ active jumps straight to archived (skips stale)

The transition engine — branch order is load-bearing

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;
};
The never-active anchor — a precise detail

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.

The two gates: provenance and pin

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.

The telemetry sidecar — best-effort reads, atomic writes

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;
}
One clock, two readers. The same 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.
1. A skill has createdBy: 'user' and hasn't been touched in a year. What does runCurator do?
Correct: c. The provenance gate touches only agent-authored skills; user/bundled/hub skills are reported as skipped with not-agent-created. (And nothing is ever deleted — archive is the terminal state.)
2. A freshly created agent skill has lastActivityAt = 0. On the first curator pass it is…
Correct: b. Creation isn't activity, so 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.
3. Why is the branch order in nextState (archive → stale → reactivate) load-bearing?
Correct: d. If stale were checked first, an old 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.

Common confusions

"Archived means deleted." No — archive is recoverable and is the terminal state by invariant ("max action = archive"). The portable core never removes anything; the .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.
"A corrupt sidecar will crash skill calls." No — reads are best-effort: a missing, unreadable, malformed-JSON, or wrong-shape sidecar all collapse to an empty map. The whole point is that a broken telemetry file never breaks the host skill call. Only an explicit write failure is surfaced as err.