Distillation

Distillation compacts and summarizes long-lived memory collections. Nothing runs automatically. B4.run consolidates or reflects only when you run b4 memory consolidate or b4 memory reflect, by hand or on your own schedule.

The two commandsCopy link to section: The two commands

Each pass has its own job:

CommandInputOutput
b4 memory consolidateOld active episodes grouped by namespace and ISO weekOne active episodic summary per selected batch
b4 memory reflectNew active semantic and episodic records per namespaceZero or more durable reflection insights

Both commands do nothing until their thresholds are met. They share these flags:

bash
b4 memory consolidate --dry-run --namespace 'workspace=my-app|route=/support' --max-batches 5
b4 memory reflect --dry-run --namespace 'workspace=my-app|route=/support' --max-batches 5
  • --dry-run selects and reports work, but constructs no model, makes no model calls, and writes nothing.
  • --namespace <prefix> narrows the pass to matching namespaces.
  • --model <id> and --provider <id> override model selection.
  • --max-batches <n> caps batches for consolidation and namespaces for reflection.
  • --cwd <path> selects another app root, as in other B4.run commands.

--namespace is a raw prefix. A short route=/support prefix works only when route is the leading namespace dimension. If the route also declares workspace, use the full canonical namespace prefix, such as workspace=my-app|route=/support. Build it with the namespace helpers rather than guessing from a filesystem path.

ConsolidationCopy link to section: Consolidation

By default, consolidation selects active episodes older than seven days, groups them by namespace and ISO week, skips groups smaller than five, and splits groups at 50 records. The oldest batches run first, so a capped run works through the backlog in order.

For each batch it:

  1. Asks the model for a summary.
  2. Writes the summary record first.
  3. Links each source as superseded by that summary.
  4. Stamps each source with the configured source-retention expiry.

Writing before linking is deliberate. A failure after the summary write can leave a redundant active summary. Linking first could hide sources with no durable replacement. The operation as a whole isn't transactional.

There is no generic safe-to-rerun guarantee. If no source got linked, a later pass can select the same batch and retry its deterministic summary ID. After a partial link failure, though, the remaining active, unstamped sources can fall below minBatchSize or be regrouped, so a later pass may never reconcile them. An expiry update can also fail after supersession, which leaves a superseded source without its planned expiry.

In both cases the command reports the affected source IDs and exits non-zero. Compare the summary's derivedFrom list with the source states, and do a manual reconciliation before the next pass.

The summary contains data.period, sourceCount, and derivedFrom. It is tagged consolidated and uses the end of the source window as effectiveAt, which keeps it newer than its own superseded sources under the episodic cap. Later consolidation passes skip derived summaries.

Superseded source rows stay inspectable for sourceTtlMs (seven days by default), and then normal pruning removes them. Without that expiry they would be invisible to recall but still count against the episodic cap, which ignores status. A source can expire before a scheduled pass if its own retention window is shorter, so line up the episode TTL, the consolidation age, and the schedule.

ReflectionCopy link to section: Reflection

Reflection asks “what have we learned?” rather than “what happened?” It works on the active semantic and episodic records of each namespace, matched in full rather than by prefix.

The pass reads the newest data.coveredUntil watermark and selects records strictly newer than it. By default it needs at least ten of them, and it sends at most maxRecords (100 by default) to the model.

With a larger backlog, only the newest window is processed and the watermark moves past it. The older rows then sit behind the watermark, and no later pass picks them up. Run reflection often enough, or raise maxRecords, so the backlog never outgrows the cap.

Insights are reflection records with derivedFrom provenance, and they default to candidate status. Approve them through the Long-term Memory candidate workflow, or set memory.distill.reflect.writes to auto.

Each insight record carries the reflection watermark. Rejecting a candidate is a hard delete, so rejecting every candidate insight deletes every persisted watermark for that pass. With no watermark left, a later run can select the same inputs and repeat the model call and its cost. Before you reject a whole batch, approve at least one valid insight or preserve the watermark some other way.

When the model returns no insights, B4.run writes a superseded no-insight sentinel. The sentinel advances the watermark and is never recalled, so a schedule won't pay again and again for the same input.

Model and data trustCopy link to section: Model and data trust

On a live pass, active memory content is sent to the configured model provider. That can include user text, tool output, semantic facts, and episodes. Apply your data-classification, residency, retention, and provider policies before you enable distillation, and redact or exclude secrets and regulated data when you collect them.

Treat stored memory as untrusted prompt input. Source content can contain prompt injection aimed at the distillation model, and model output can be wrong or malicious. Consolidation output is written active before source linking begins. Reflection output is a candidate by default, and you can configure it to be active too.

So review and monitor derived records, restrict model and provider changes, and alert on unexpected summaries, insights, or link failures.

Distilled records are found by keywordCopy link to section: Distilled records are found by keyword

Consolidation and reflection writes skip embeddings for now. You'll find summaries and insights through keyword recall, query-less recall, and time filters. If derived records must be reachable by semantic recall, add your own re-embedding workflow and test how it handles model ids.

Use specific, stable words in prompts and outputs so later keyword queries find the result. Names, ticket IDs, and domain terms help the most.

ProvenanceCopy link to section: Provenance

Every derived record lists its source IDs in data.derivedFrom. Consolidation also links sources through supersession. Reflection leaves its inputs active and records the covered watermark. These links are an audit trail. Retention can still delete the source rows.

Decide how long operators need the evidence, and whether an external audit store must keep it. Distillation provides no account-erasure transaction and no immutable archive.

CostCopy link to section: Cost

Each selected consolidation batch uses one model call. Each selected reflection namespace uses one model call, even when it produces the no-insight sentinel. Control spend with thresholds, maxBatches, a narrow namespace prefix, and --dry-run before the first live pass.

The default model is gpt-5-mini. The provider follows the resolved model unless you set one explicitly. In production, pin the model and provider if a change would affect cost or output shape.

Running it on a scheduleCopy link to section: Running it on a schedule

Run the commands from cron or a job runner. Empty passes do no model work. Dry-run with the same namespace and batch cap first, and go live after reviewing the counts. A non-zero exit means something needs reconciling, so don't retry blindly.

Schedule against a single application-owned store, and keep jobs for the same namespace from overlapping. The write/link sequence and the reflection watermarks help with retries, but they aren't a distributed lease. Alert on failed batches and on “more not examined” output, so a capped backlog doesn't grow unnoticed.

Distillation configurationCopy link to section: Distillation configuration

These are the defaults:

b4.config.ts
export default {
  memory: {
    distill: {
      model: "gpt-5-mini",
      maxBatches: 5,
      retry: { maxAttempts: 3 },
      consolidate: {
        olderThanMs: 7 * 24 * 60 * 60 * 1000,
        minBatchSize: 5,
        maxBatchSize: 50,
        sourceTtlMs: 7 * 24 * 60 * 60 * 1000,
      },
      reflect: {
        minNewRecords: 10,
        maxRecords: 100,
        writes: "candidate",
      },
    },
  },
} satisfies import("@b4run/core").B4Config

retry.maxAttempts is the attempts per model call, counting the first; 1 sends each call once. maxAttempts - 1 becomes the model's maxRetries, which LangChain applies to server errors, network errors and rate limits with a Retry-After of 60 seconds or less (see Retry). A pass runs outside any route, so no route's agent({ retry }) applies. retry takes only maxAttempts: a baseDelay, a misspelled or misplaced key, or a maxAttempts that isn't a whole number of at least 1 fails the command with B4_E1009 before any model is built, and b4 check reports the same. Configuration lists every shape it rejects.

consolidate.ttlMs sets an expiry on summaries. Leave it unset to keep them. If a summary should outlive its evidence, make sure its TTL is longer than the source review window.

TestingCopy link to section: Testing

Use a fake model and fixed now to assert selection and writes:

  1. --dry-run creates no model and performs no write.
  2. Consolidation writes before attempting supersession.
  3. A link failure leaves the summary available for reconciliation.
  4. Source expiry is stamped, and derived summaries aren't selected again.
  5. Reflection respects the per-namespace watermark and the maxRecords bound.
  6. A zero-insight response writes only the superseded sentinel.
  7. Reflection output defaults to candidates and stays keyword-reachable.
  8. Batch caps report unexamined work.

Keep model-output parsing tests separate from store lifecycle tests, so a failure tells you whether selection, generation, or persistence broke.