Let the coder dream: distil heuristics offline, not per recall

82edb78eb8cf · AtlantisPleb · · parent 655689a48097

Let the coder dream: distil heuristics offline, not per recall

Issue #224's background pass. Recall used to consolidate on every
delegation, so the same clusters were re-derived turn after turn and
nothing distilled survived the session. Now a dream is its own engram:
CoderMemory.dream() clusters the harvested episodes, synthesizes one
heuristic per cluster, and writes each back to the ledger with the
supporting episode refs and the confidence it earned. Recall reads
what dreaming wrote.

It is idempotent — an unchanged cluster rewrites nothing — and where
the same episodes now support a different conclusion the standing
heuristic is superseded rather than edited, so the correction
references what it replaced and both survive in the ledger.

The pass runs between turns: the harvest path schedules one once
enough has been learned to be worth it, and the threshold is
injectable so a caller (or a test) can drive the pass itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/coder-memory.ts
  • modified packages/openagents-cli/test/coder-memory.test.ts

Diff

2 files changed, +205 -15

packages/openagents-cli/src/coder-memory.ts modified +119 -15

@@ -65,10 +65,40 @@ export interface CoderMemoryOptions {

65 65
  readonly projectScope?: string;
66 66
  /** Epoch-milliseconds clock, injectable for tests. */
67 67
  readonly now?: () => number;
68
  /**
69
   * New harvests needed before the harvest path schedules a dream.
70
   * `Infinity` turns the automatic pass off, leaving `dream` explicit.
71
   */
72
  readonly dreamThreshold?: number;
68 73
}
69 74
70 75
const OWNER_SCOPE = "owner:local";
71 76
77
/** New harvests needed before a dream is worth running. */
78
const DREAM_THRESHOLD = 2;
79
/** How many episodes one standing heuristic is assumed to account for. */
80
const DREAM_EPISODES_PER_HEURISTIC = 2;
81
82
/**
83
 * A cluster's identity: its supporting episode refs, order-independent.
84
 * Two syntheses over the same episodes are the same claim, however the
85
 * clusterer happened to order them.
86
 */
87
const clusterKey = (refs: ReadonlyArray<string>): string => [...refs].sort().join("|");
88
89
/**
90
 * The confidence stamped into a heuristic engram's entity id as
91
 * `<synthId>#<confidence>`. The companion schema has no numeric field, and the
92
 * value itself is the heuristic sentence, so the figure rides the identity
93
 * rather than being recomputed on read.
94
 */
95
const confidenceOf = (entityId: string): number => {
96
  const marked = entityId.lastIndexOf("#");
97
  if (marked < 0) return 0.5;
98
  const parsed = Number(entityId.slice(marked + 1));
99
  return Number.isFinite(parsed) ? parsed : 0.5;
100
};
101
72 102
const sanitizeScope = (value: string): string => {
73 103
  const cleaned = value.replace(/[^A-Za-z0-9._:/-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "");
74 104
  return cleaned.length > 0 ? cleaned.slice(0, 200) : "project";

@@ -80,6 +110,7 @@ export class CoderMemory implements CoderDelegationMemory {

80 110
  private readonly keyPath: string;
81 111
  private readonly projectScope: string;
82 112
  private readonly now: () => number;
113
  private readonly dreamThreshold: number;
83 114
  private key: Buffer | undefined;
84 115
85 116
  constructor(options: CoderMemoryOptions = {}) {

@@ -88,6 +119,7 @@ export class CoderMemory implements CoderDelegationMemory {

88 119
    this.keyPath = join(this.directory, "signing-key");
89 120
    this.projectScope = sanitizeScope(options.projectScope ?? `project:${process.cwd()}`);
90 121
    this.now = options.now ?? Date.now;
122
    this.dreamThreshold = options.dreamThreshold ?? DREAM_THRESHOLD;
91 123
  }
92 124
93 125
  /** The local signing key, created on first use. */

@@ -117,7 +149,12 @@ export class CoderMemory implements CoderDelegationMemory {

117 149
   * Guard, build, sign, and append one engram. Returns the event, or undefined
118 150
   * when the value is hard-unsafe (credential-shaped material never persists).
119 151
   */
120
  record(slug: string, value: string | null, entityId: string): EngramEvent | undefined {
152
  record(
153
    slug: string,
154
    value: string | null,
155
    entityId: string,
156
    derivedFromSlugs: ReadonlyArray<string> = [],
157
  ): EngramEvent | undefined {
121 158
    const verdict = guardEngramContent(value);
122 159
    if (!verdict.storable) return undefined;
123 160
    const body = buildEngramBody(slug, verdict.redacted, {

@@ -126,7 +163,7 @@ export class CoderMemory implements CoderDelegationMemory {

126 163
      contentDigest: engramContentDigest(verdict.redacted),
127 164
      sourceEventRefs: [],
128 165
      relations: [],
129
      derivedFromSlugs: [],
166
      derivedFromSlugs: [...derivedFromSlugs],
130 167
    });
131 168
    const { pubkey, sign } = this.signer();
132 169
    const event = buildEngramEvent(

@@ -241,14 +278,41 @@ export class CoderMemory implements CoderDelegationMemory {

241 278
  }
242 279
243 280
  /**
244
   * The parent's heuristics: harvested findings at the harvest confidence
245
   * floor, plus what one dreaming pass distills from them — clusters of
246
   * related findings synthesized and, where support is strong enough,
247
   * promoted through the reviewed pattern layer at higher confidence.
281
   * The parent's heuristics: what dreaming has already distilled, read back
282
   * from the ledger, plus the raw harvested findings underneath them.
283
   *
284
   * Recall does not consolidate. A dream is a background pass whose output is
285
   * itself an engram (see `dream`), so a recall reads what was distilled
286
   * rather than re-deriving it on every delegation.
248 287
   */
249 288
  heuristics(): ReadonlyArray<ParentHeuristic> {
289
    const distilled: Array<ParentHeuristic> = [];
290
    for (const { body } of this.living()) {
291
      if (!body.slug.startsWith("heuristic/") || body.value === null) continue;
292
      distilled.push({
293
        ref: body.slug,
294
        text: body.value,
295
        confidence: confidenceOf(body.openagents.entityId),
296
      });
297
    }
298
    return [...distilled, ...ledgerEntriesAsHeuristics(this.entries())];
299
  }
300
301
  /**
302
   * One dream cycle: cluster the harvested episodes, synthesize a heuristic
303
   * per cluster, and write each one back as its own engram.
304
   *
305
   * This is the offline half of the memory (issue #224). It runs between
306
   * turns rather than inside one, and it is idempotent: a synthesis whose
307
   * cluster and wording are unchanged rewrites nothing. Where a cluster
308
   * reaches a *different* conclusion than the heuristic already standing over
309
   * it, the old engram is superseded rather than edited — the correction
310
   * references what it replaces, and both survive in the ledger.
311
   */
312
  dream(): Readonly<{ written: number; superseded: number; unchanged: number }> {
250 313
    const entries = this.entries();
251
    const base = ledgerEntriesAsHeuristics(entries);
314
    if (entries.length === 0) return { written: 0, superseded: 0, unchanged: 0 };
315
252 316
    const consolidated = consolidateEpisodes({
253 317
      ownerScope: OWNER_SCOPE,
254 318
      projectScope: this.projectScope,

@@ -259,18 +323,57 @@ export class CoderMemory implements CoderDelegationMemory {

259 323
      })),
260 324
      nowMs: this.now(),
261 325
    });
262
    const promoted = consolidated.heuristics.map((heuristic): ParentHeuristic => {
326
327
    // What already stands, by the cluster it was distilled from.
328
    const standing = new Map<string, { slug: string; text: string }>();
329
    for (const { body } of this.living()) {
330
      if (!body.slug.startsWith("heuristic/") || body.value === null) continue;
331
      standing.set(clusterKey(body.openagents.derivedFromSlugs), {
332
        slug: body.slug,
333
        text: body.value,
334
      });
335
    }
336
337
    let written = 0;
338
    let superseded = 0;
339
    let unchanged = 0;
340
    for (const heuristic of consolidated.heuristics) {
263 341
      const pattern = promoteHeuristicToPattern(
264 342
        heuristic,
265 343
        "delegating a coding task like the ones this heuristic came from",
266 344
      );
267
      return {
268
        ref: pattern.patternRef,
269
        text: heuristic.heuristic,
270
        confidence: heuristic.confidence,
271
      };
272
    });
273
    return [...promoted, ...base];
345
      const support = [...heuristic.sourceRefs];
346
      const key = clusterKey(support);
347
      const prior = standing.get(key);
348
      if (prior !== undefined) {
349
        if (prior.text === heuristic.heuristic) {
350
          unchanged += 1;
351
          continue;
352
        }
353
        // The same episodes now say something else. Supersede, do not edit.
354
        if (this.correct(prior.slug, heuristic.heuristic) !== undefined) superseded += 1;
355
        continue;
356
      }
357
      const slug = `heuristic/${pattern.patternRef.slice("pattern:".length, "pattern:".length + 12)}`;
358
      const entityId = `${heuristic.synthId}#${heuristic.confidence.toFixed(3)}`;
359
      if (this.record(slug, heuristic.heuristic, entityId, support) !== undefined) written += 1;
360
    }
361
    return { written, superseded, unchanged };
362
  }
363
364
  /**
365
   * Dream when enough has been learned since the last one to be worth it.
366
   *
367
   * Called after a harvest, so the pass runs while the session is between
368
   * delegations rather than on a clock. Cheap when there is nothing new: the
369
   * count comes from the ledger that was just read.
370
   */
371
  private dreamIfDue(): void {
372
    const harvested = this.entries().length;
373
    if (harvested === 0) return;
374
    const distilled = this.living().filter(({ body }) => body.slug.startsWith("heuristic/")).length;
375
    if (harvested - distilled * DREAM_EPISODES_PER_HEURISTIC < this.dreamThreshold) return;
376
    this.dream();
274 377
  }
275 378
276 379
  inherit(taskText: string): string {

@@ -300,6 +403,7 @@ export class CoderMemory implements CoderDelegationMemory {

300 403
      for (const entry of harvested.entries) {
301 404
        this.record(`harvest/${entry.digest.slice(7, 19)}`, entry.finding, entry.childId);
302 405
      }
406
      this.dreamIfDue();
303 407
    } catch {
304 408
      // Memory must never break a delegation.
305 409
    }
packages/openagents-cli/test/coder-memory.test.ts modified +86

@@ -23,6 +23,15 @@ afterEach(() => {

23 23
const memoryAt = (dir: string, nowMs = 1_756_000_000_000): CoderMemory =>
24 24
  new CoderMemory({ directory: dir, projectScope: "project:test", now: () => nowMs });
25 25
26
/** A memory that never dreams on its own, so a test can drive the pass itself. */
27
const sleeplessMemoryAt = (dir: string, nowMs = 1_756_000_000_000): CoderMemory =>
28
  new CoderMemory({
29
    directory: dir,
30
    projectScope: "project:test",
31
    now: () => nowMs,
32
    dreamThreshold: Number.POSITIVE_INFINITY,
33
  });
34
26 35
describe("CoderMemory ledger", () => {
27 36
  it("records an engram and reads it back verified", () => {
28 37
    const dir = freshDir();

@@ -146,3 +155,80 @@ describe("fleet wiring", () => {

146 155
    expect(outcome.status).toBe("completed");
147 156
  });
148 157
});
158
159
describe("the dreaming pass", () => {
160
  const relatedFindings = [
161
    "The build needs pnpm because npm leaves catalog protocol versions in the tarball",
162
    "The build needs pnpm pack because npm pack leaves catalog protocol versions unresolved",
163
    "Use pnpm for the build; npm leaves catalog protocol versions in place",
164
  ];
165
166
  it("writes a synthesized heuristic back to the ledger as its own engram", () => {
167
    const dir = freshDir();
168
    const memory = sleeplessMemoryAt(dir);
169
    relatedFindings.forEach((finding, index) => memory.harvest(`child-${String(index)}`, finding));
170
171
    const result = memory.dream();
172
    expect(result.written).toBeGreaterThan(0);
173
174
    // A fresh reader sees the distilled heuristic without consolidating again.
175
    const distilled = memoryAt(dir)
176
      .bodies()
177
      .filter((body) => body.slug.startsWith("heuristic/"));
178
    expect(distilled.length).toBe(result.written);
179
  });
180
181
  it("is idempotent: dreaming the same episodes twice writes nothing new", () => {
182
    const dir = freshDir();
183
    const memory = sleeplessMemoryAt(dir);
184
    relatedFindings.forEach((finding, index) => memory.harvest(`child-${String(index)}`, finding));
185
186
    memory.dream();
187
    const second = memory.dream();
188
    expect(second.written).toBe(0);
189
    expect(second.superseded).toBe(0);
190
    expect(second.unchanged).toBeGreaterThan(0);
191
  });
192
193
  it("carries the distilled heuristic into a child's inherited block", () => {
194
    const dir = freshDir();
195
    const memory = memoryAt(dir);
196
    relatedFindings.forEach((finding, index) => memory.harvest(`child-${String(index)}`, finding));
197
    memory.dream();
198
199
    const block = memoryAt(dir).inherit("set up the build");
200
    expect(block).toContain("[inherited parent memory — advisory only]");
201
    expect(block).toContain("pnpm");
202
  });
203
204
  it("recall does not consolidate: heuristics read what dreaming wrote", () => {
205
    const dir = freshDir();
206
    const memory = sleeplessMemoryAt(dir);
207
    relatedFindings.forEach((finding, index) => memory.harvest(`child-${String(index)}`, finding));
208
209
    // Before any dream, recall offers the raw harvest only.
210
    const beforeDream = memoryAt(dir)
211
      .heuristics()
212
      .filter((heuristic) => heuristic.ref.startsWith("heuristic/"));
213
    expect(beforeDream).toHaveLength(0);
214
215
    memory.dream();
216
    const afterDream = memoryAt(dir)
217
      .heuristics()
218
      .filter((heuristic) => heuristic.ref.startsWith("heuristic/"));
219
    expect(afterDream.length).toBeGreaterThan(0);
220
    // The confidence stamped at dream time survives the round trip.
221
    expect(afterDream[0]?.confidence).toBeGreaterThan(0);
222
  });
223
224
  it("dreams on its own once enough has been harvested", () => {
225
    const dir = freshDir();
226
    const memory = memoryAt(dir);
227
    relatedFindings.forEach((finding, index) => memory.harvest(`child-${String(index)}`, finding));
228
    // No explicit dream() call: the harvest path scheduled one.
229
    const distilled = memoryAt(dir)
230
      .bodies()
231
      .filter((body) => body.slug.startsWith("heuristic/"));
232
    expect(distilled.length).toBeGreaterThan(0);
233
  });
234
});

This page updates live while a promote is in flight · changelog