Revert "Surface memories on the retrieval rails"

19098b54939e · AtlantisPleb · · parent b79105bb7dfe

Revert "Surface memories on the retrieval rails"

This reverts 74a10e0d97. The implementation targeted the local engram
ledger, which openagents#51's redesign froze: memories live in the cloud
database, recall happens server-side inside the responses endpoint, and no
new surfacing work targets ~/.openagents/memory. The delegated work read the
issue body and not its comments, so it built the superseded design. #51 is
reopened for the cloud implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
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/cli.ts
  • modified packages/openagents-cli/src/coder-memory.ts
  • deleted packages/openagents-cli/src/coder-recall.ts
  • deleted packages/openagents-cli/test/coder-recall.test.ts

Diff

4 files changed, +0 -582

packages/openagents-cli/src/cli.ts modified -18

@@ -104,7 +104,6 @@ import {

104 104
  matchCapabilities,
105 105
} from "./coder-capability.js";
106 106
import { knowledgeHits, knowledgeNote } from "./coder-knowledge.js";
107
import { memoryRecallNote, rememberTool } from "./coder-recall.js";
108 107
import { runForeignResume } from "./coder-foreign-resume.js";
109 108
import { existsSync } from "node:fs";
110 109
import { spawnSync } from "node:child_process";

@@ -2604,12 +2603,6 @@ const coderCommand = Command.make(

2604 2603
        recordGap: defaultCapabilityGapRecorder(),
2605 2604
        onSelect,
2606 2605
      });
2607
      // The session's read/write handle on the local engram ledger
2608
      // (OpenAgentsInc/openagents#51). The `remember` tool writes the user
2609
      // bucket through it, and the retrieval rail below recalls from it on
2610
      // every message; the delegate fleet holds its own handle on the same
2611
      // append-only ledger, so the two never disagree about what is written.
2612
      const recallMemory = new CoderMemory({ projectScope: `project:${process.cwd()}` });
2613 2606
      let declareTools: () => void;
2614 2607
      declareTools = () => {
2615 2608
        const active = skills.active();

@@ -2623,7 +2616,6 @@ const coderCommand = Command.make(

2623 2616
          // being asked for. Re-declaration per turn is what makes the tool
2624 2617
          // appear the turn after `/goal` sets one.
2625 2618
          ...(goalStore.getGoal() === undefined ? [] : [goalTool(goalStore)]),
2626
          rememberTool(recallMemory),
2627 2619
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2628 2620
          capability,
2629 2621
          ...visiblePlugins().map((plugin) => {

@@ -2708,16 +2700,6 @@ const coderCommand = Command.make(

2708 2700
        const fromKnowledge = await knowledgeBaseNote(prompt);
2709 2701
        if (fromKnowledge !== undefined) notes.push(fromKnowledge);
2710 2702
2711
        // Memory rides the same rail (OpenAgentsInc/openagents#51): the live
2712
        // engram ledger is ranked against this message and what clears the
2713
        // floor arrives as attached context — no tool call needed to recall.
2714
        try {
2715
          const fromMemory = memoryRecallNote(recallMemory.recallable(), prompt, Date.now());
2716
          if (fromMemory !== undefined) notes.push(fromMemory);
2717
        } catch {
2718
          // The rail degrades to silence, never to a broken turn.
2719
        }
2720
2721 2703
        // The knowledge base is a rail, not a tool: it never materializes.
2722 2704
        const matches = matchCapabilities(catalog, prompt).filter(
2723 2705
          (candidate) => candidate.entry.name !== "knowledge_base",
packages/openagents-cli/src/coder-memory.ts modified -63

@@ -41,8 +41,6 @@ import {

41 41
} from "./memory/index.js";
42 42
import { Schema as S } from "effect";
43 43
44
import type { RecallableMemory } from "./coder-recall.js";
45
46 44
/**
47 45
 * The coder's own memory: a local, append-only ledger of signed engrams.
48 46
 *

@@ -388,67 +386,6 @@ export class CoderMemory implements CoderDelegationMemory {

388 386
    return projectedValue(this.projection(), slug);
389 387
  }
390 388
391
  /**
392
   * Store one fact the reader explicitly asked to have remembered — the user
393
   * bucket's write path (OpenAgentsInc/openagents#51). The slug derives from
394
   * the content, so remembering the same sentence twice supersedes the prior
395
   * event under its slug rather than forking the chain into refusal. The
396
   * value still passes the redaction guard inside `record`; a hard-unsafe
397
   * fact stores nothing and this returns undefined.
398
   */
399
  remember(fact: string): EngramEvent | undefined {
400
    try {
401
      const trimmed = fact.trim().slice(0, 1000);
402
      if (trimmed.length === 0) return undefined;
403
      const slug = `user/${engramContentDigest(trimmed).slice("sha256:".length, "sha256:".length + 12)}`;
404
      if (this.chains().has(slug)) return this.correct(slug, trimmed);
405
      return this.record(slug, trimmed, "user-note");
406
    } catch {
407
      // Memory must never break a turn; a fact that could not be written is
408
      // reported as undefined and the tool says so in words.
409
      return undefined;
410
    }
411
  }
412
413
  /**
414
   * Everything the retrieval rail may surface, live values only: the user
415
   * bucket (slugs under `user/`, explicitly asked for) and the learned bucket
416
   * (slugs under `heuristic/`, what consolidation distilled, each carrying its
417
   * slug and supporting episode slugs as provenance so a wrong learning can be
418
   * superseded by name). Harvest episodes stay out — they are raw material,
419
   * not conclusions. Never throws: an empty, missing, corrupt, or unreadable
420
   * ledger is simply no memories.
421
   */
422
  recallable(): ReadonlyArray<RecallableMemory> {
423
    try {
424
      const memories: Array<RecallableMemory> = [];
425
      for (const { body, createdAtMs } of this.living()) {
426
        if (body.value === null) continue;
427
        if (body.slug.startsWith("user/")) {
428
          memories.push({
429
            bucket: "user",
430
            ref: body.slug,
431
            text: body.value,
432
            recordedAtMs: createdAtMs,
433
          });
434
        } else if (body.slug.startsWith("heuristic/")) {
435
          memories.push({
436
            bucket: "learned",
437
            ref: body.slug,
438
            text: body.value,
439
            recordedAtMs: createdAtMs,
440
            confidence: confidenceOf(body.openagents.entityId),
441
            provenance: [...body.openagents.derivedFromSlugs],
442
          });
443
        }
444
      }
445
      return memories;
446
    } catch {
447
      // The rail degrades to silence, never to a broken turn.
448
      return [];
449
    }
450
  }
451
452 389
  /**
453 390
   * The harvested ledger entries currently alive in the engram stream.
454 391
   *
packages/openagents-cli/src/coder-recall.ts deleted -215

@@ -1,215 +0,0 @@

1
/**
2
 * The memory rail (OpenAgentsInc/openagents#51): the harness recalls from the
3
 * local engram ledger on every incoming message and attaches what qualifies to
4
 * the outgoing turn as a bounded note. The model never calls a tool to
5
 * remember — by the time it writes, what this agent already holds is in front
6
 * of it, labelled by bucket, kind, and age, the same way the knowledge base
7
 * (#49) and the capability catalog (#42) already ride this rail.
8
 *
9
 * Two buckets, kept distinct in the note because their authority differs:
10
 *
11
 * - `user`: facts the reader explicitly asked to have remembered. An explicit
12
 *   request to remember is standing consent to be reminded, so these attach
13
 *   whenever they exist — the acceptance case is precisely a preference
14
 *   ("I use pnpm, not npm") that shares no vocabulary with the message that
15
 *   needs it ("install the deps").
16
 * - `learned`: what consolidation distilled from past sessions. A distilled
17
 *   heuristic is a guess about the reader, not a promise from them, so it must
18
 *   at least share vocabulary with the message before it interrupts, and it is
19
 *   surfaced with provenance — its ledger slug and the episode count under
20
 *   it — so a wrong learning can be traced and superseded through the existing
21
 *   superseding-event path rather than argued with.
22
 *
23
 * Ranking and packing reuse the owned ranking module (salience recall,
24
 * token-budget packing); nothing here re-derives an ordering of its own. The
25
 * note builder is pure, and the rail around it degrades to silence, never to
26
 * a broken turn.
27
 */
28
29
import { estimateTokens, packWithinBudget, recallOrderBySalience } from "./memory/ranking.js";
30
import type { CoderTool } from "./coder-tools.js";
31
32
/** One live ledger memory as the store offers it to the rail. */
33
export interface RecallableMemory {
34
  readonly bucket: "user" | "learned";
35
  /**
36
   * The ledger slug — the handle a correction is filed under. For a learned
37
   * memory this is what the note surfaces so a wrong heuristic can be
38
   * superseded by name.
39
   */
40
  readonly ref: string;
41
  readonly text: string;
42
  /** When the live revision of this memory was recorded, epoch milliseconds. */
43
  readonly recordedAtMs: number;
44
  /** Consolidation's confidence in a learned memory; absent for user notes. */
45
  readonly confidence?: number;
46
  /** The episode slugs a learned memory was distilled from. */
47
  readonly provenance?: ReadonlyArray<string>;
48
}
49
50
/**
51
 * The salience a memory needs before it is worth attaching. A user note always
52
 * clears it; a learned heuristic clears it only with lexical overlap lifting
53
 * it past its confidence base.
54
 */
55
export const MEMORY_ATTACH_FLOOR = 0.5;
56
57
/** Most memories one note carries; more is noise, not context. */
58
export const MEMORY_NOTE_LIMIT = 5;
59
60
/** The note's token budget, enforced by the owned packer. */
61
export const MEMORY_NOTE_BUDGET_TOKENS = 320;
62
63
/** A memory's share of one line; a runaway value is clipped, not dropped. */
64
const TEXT_BOUND = 300;
65
66
const clipped = (text: string): string =>
67
  text.length <= TEXT_BOUND ? text : `${text.slice(0, TEXT_BOUND - 1).trimEnd()}…`;
68
69
/** Lowercased words of three letters or more; the rest is noise. */
70
const tokensOf = (text: string): string[] =>
71
  text
72
    .toLowerCase()
73
    .split(/[^a-z0-9]+/)
74
    .filter((word) => word.length >= 3);
75
76
/**
77
 * The salience fed to the owned recall ordering. A user note starts at 1 —
78
 * explicitly asked for, always above the floor — and overlap only sharpens its
79
 * rank. A learned heuristic with no overlap scores 0 and stays silent; with
80
 * overlap it starts from consolidation's confidence.
81
 */
82
const salienceOf = (memory: RecallableMemory, hits: number): number =>
83
  memory.bucket === "user"
84
    ? 1 + hits * 0.25
85
    : hits === 0
86
      ? 0
87
      : (memory.confidence ?? 0.5) + hits * 0.25;
88
89
/** A coarse human age: "new" under a minute, then minutes, hours, days. */
90
const agePhrase = (ageMs: number): string => {
91
  const ms = Math.max(0, ageMs);
92
  if (ms < 60_000) return "new";
93
  const minutes = Math.floor(ms / 60_000);
94
  if (minutes < 60) return `${minutes}m old`;
95
  const hours = Math.floor(minutes / 60);
96
  if (hours < 24) return `${hours}h old`;
97
  return `${Math.floor(hours / 24)}d old`;
98
};
99
100
const line = (memory: RecallableMemory, nowMs: number): string => {
101
  const age = agePhrase(nowMs - memory.recordedAtMs);
102
  if (memory.bucket === "user") {
103
    return `- (user note, ${age}) ${clipped(memory.text)}`;
104
  }
105
  const episodes = memory.provenance?.length ?? 0;
106
  const support = episodes === 0 ? "" : `, from ${episodes} episode${episodes === 1 ? "" : "s"}`;
107
  return `- (learned heuristic ${memory.ref}, ${age}${support}) ${clipped(memory.text)}`;
108
};
109
110
/**
111
 * The note for one message, or nothing when no memory clears the floor.
112
 *
113
 * Selection is the owned ranking module end to end: the floor filters, salience
114
 * recall orders (salience plus recency), the limit is the top-K, and the token
115
 * budget is enforced by the owned packer over the rendered lines. Pure and
116
 * deterministic: equal inputs give an equal note.
117
 */
118
export const memoryRecallNote = (
119
  memories: ReadonlyArray<RecallableMemory>,
120
  prompt: string,
121
  nowMs: number,
122
): string | undefined => {
123
  if (memories.length === 0) return undefined;
124
  const terms = tokensOf(prompt);
125
  const scored = memories
126
    .map((memory, index) => {
127
      const haystack = new Set(tokensOf(memory.text));
128
      const hits = terms.filter((term) => haystack.has(term)).length;
129
      // The ref fed to the ranking module is positional, not the ledger slug:
130
      // two memories may legitimately share a slug prefix, and the ranking
131
      // contract wants refs unique within one call.
132
      return { memory, ref: `m${index}`, salience: salienceOf(memory, hits) };
133
    })
134
    .filter((candidate) => candidate.salience >= MEMORY_ATTACH_FLOOR);
135
  if (scored.length === 0) return undefined;
136
137
  const byRef = new Map(scored.map((candidate) => [candidate.ref, candidate]));
138
  const ordered = recallOrderBySalience(
139
    scored.map((candidate) => ({
140
      ref: candidate.ref,
141
      salience: candidate.salience,
142
      lastUsedAt: candidate.memory.recordedAtMs,
143
    })),
144
    nowMs,
145
  ).slice(0, MEMORY_NOTE_LIMIT);
146
147
  const lines = new Map(ordered.map((ref) => [ref, line(byRef.get(ref)!.memory, nowMs)] as const));
148
  const packed = packWithinBudget(
149
    ordered.map((ref) => ({
150
      ref,
151
      priority: byRef.get(ref)!.salience,
152
      tokens: estimateTokens(`${lines.get(ref) ?? ""}\n`),
153
    })),
154
    MEMORY_NOTE_BUDGET_TOKENS,
155
  );
156
  const included = new Set(packed.included);
157
  const kept = ordered.filter((ref) => included.has(ref));
158
  if (kept.length === 0) return undefined;
159
160
  return (
161
    "[From memory — what this agent already holds about this reader and this work. " +
162
    "`user` entries were explicitly asked for; `learned` entries were distilled from " +
163
    "earlier sessions and can be wrong — a wrong one is superseded under its named slug, " +
164
    "never argued with:\n" +
165
    kept.map((ref) => lines.get(ref) ?? "").join("\n") +
166
    "]"
167
  );
168
};
169
170
/**
171
 * The explicit write path for the user bucket. The tool exists only so the
172
 * reader's "remember that …" lands in the ledger; recall never goes through
173
 * it — the rail attaches memories without a call. Explicit only, never
174
 * inferred: the description tells the model to use it solely on a direct
175
 * request, and everything it stores is labelled `user` on the way back out.
176
 */
177
export const rememberTool = (memory: { remember(fact: string): unknown }): CoderTool => ({
178
  name: "remember",
179
  description:
180
    "Store one fact the reader explicitly asked to have remembered, such as " +
181
    '"remember that I use pnpm, not npm". Call this only when the reader directly asks ' +
182
    "for something to be remembered — never to record your own inferences or ambient " +
183
    "observations. Stored facts are recalled automatically on later messages; no tool " +
184
    "call is needed to read them back.",
185
  parameters: {
186
    type: "object",
187
    properties: {
188
      fact: {
189
        type: "string",
190
        description: "The fact to remember, in one bounded sentence, as the reader stated it.",
191
      },
192
    },
193
    required: ["fact"],
194
  },
195
  run: async (args: Record<string, unknown>): Promise<string> => {
196
    const fact = typeof args["fact"] === "string" ? args["fact"].trim() : "";
197
    if (fact.length === 0) {
198
      return "Nothing to remember: `fact` must be a non-empty sentence.";
199
    }
200
    try {
201
      const stored = memory.remember(fact);
202
      if (stored === undefined) {
203
        return (
204
          "That was not stored. The memory guard refuses credential-shaped material " +
205
          "outright; rephrase the fact without the secret if the reader still wants it kept."
206
        );
207
      }
208
      return "Remembered. It will be recalled automatically when a later message needs it.";
209
    } catch {
210
      // A tool reports a refusal as text rather than by throwing: the model
211
      // can act on words and cannot act on a turn that died.
212
      return "That was not stored: the memory ledger is not writable right now.";
213
    }
214
  },
215
});
packages/openagents-cli/test/coder-recall.test.ts deleted -286

@@ -1,286 +0,0 @@

1
/**
2
 * Memory on the retrieval rail (OpenAgentsInc/openagents#51): the note format,
3
 * the ranking integration with the owned ranking module, the bucket labels,
4
 * the `remember` write path, and the degrade-to-silence posture on an empty,
5
 * corrupt, or unreadable ledger. The rail itself is one call in the harness
6
 * (`memoryRecallNote(memory.recallable(), prompt, now)`), so these tests pin
7
 * that seam the way the knowledge-base tests pin theirs: the store on one
8
 * side, the pure note builder on the other, and the acceptance scenario end
9
 * to end across both.
10
 */
11
12
import { appendFileSync, chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
import { tmpdir } from "node:os";
14
import { join } from "node:path";
15
import { afterEach, describe, expect, it } from "vitest";
16
17
import { CoderMemory } from "../src/coder-memory.js";
18
import {
19
  MEMORY_ATTACH_FLOOR,
20
  MEMORY_NOTE_LIMIT,
21
  memoryRecallNote,
22
  rememberTool,
23
  type RecallableMemory,
24
} from "../src/coder-recall.js";
25
26
const NOW = 1_756_000_000_000;
27
const DAY = 24 * 60 * 60 * 1000;
28
29
const dirs: Array<string> = [];
30
const freshDir = (): string => {
31
  const dir = mkdtempSync(join(tmpdir(), "coder-recall-test-"));
32
  dirs.push(dir);
33
  return dir;
34
};
35
36
afterEach(() => {
37
  for (const dir of dirs.splice(0)) {
38
    rmSync(dir, { recursive: true, force: true });
39
  }
40
});
41
42
const memoryAt = (dir: string, nowMs = NOW): CoderMemory =>
43
  new CoderMemory({ directory: dir, projectScope: "project:test", now: () => nowMs });
44
45
const userMemory = (text: string, recordedAtMs = NOW - 3 * DAY): RecallableMemory => ({
46
  bucket: "user",
47
  ref: "user/abc123def456",
48
  text,
49
  recordedAtMs,
50
});
51
52
const learnedMemory = (
53
  text: string,
54
  overrides: Partial<RecallableMemory> = {},
55
): RecallableMemory => ({
56
  bucket: "learned",
57
  ref: "heuristic/ab12cd34ef56",
58
  text,
59
  recordedAtMs: NOW - 12 * DAY,
60
  confidence: 0.75,
61
  provenance: ["harvest/aaa", "harvest/bbb"],
62
  ...overrides,
63
});
64
65
describe("the memory note format", () => {
66
  it("labels a user note with its bucket, kind, and age", () => {
67
    const note = memoryRecallNote([userMemory("I use pnpm, not npm")], "install the deps", NOW);
68
    expect(note).toBeDefined();
69
    expect(note).toContain("[From memory");
70
    expect(note).toContain("- (user note, 3d old) I use pnpm, not npm");
71
  });
72
73
  it("labels a learned heuristic with its slug, age, and episode provenance", () => {
74
    const note = memoryRecallNote(
75
      [learnedMemory("prefer ranged reads over full file dumps")],
76
      "read the config file with a ranged read",
77
      NOW,
78
    );
79
    expect(note).toBeDefined();
80
    expect(note).toContain(
81
      "- (learned heuristic heuristic/ab12cd34ef56, 12d old, from 2 episodes) " +
82
        "prefer ranged reads over full file dumps",
83
    );
84
  });
85
86
  it("keeps the two buckets distinct in one note", () => {
87
    const note = memoryRecallNote(
88
      [
89
        userMemory("I use pnpm, not npm"),
90
        learnedMemory("install dependencies before running tests"),
91
      ],
92
      "install the project dependencies",
93
      NOW,
94
    );
95
    expect(note).toContain("(user note,");
96
    expect(note).toContain("(learned heuristic heuristic/");
97
  });
98
99
  it("says nothing when there are no memories at all", () => {
100
    expect(memoryRecallNote([], "install the deps", NOW)).toBeUndefined();
101
  });
102
});
103
104
describe("ranking on the rail", () => {
105
  it("always attaches a user note, even with no lexical overlap", () => {
106
    // The acceptance scenario: the remembered preference shares no vocabulary
107
    // with the message that needs it, and it must still arrive.
108
    const note = memoryRecallNote([userMemory("I use pnpm, not npm")], "install the deps", NOW);
109
    expect(note).toContain("pnpm");
110
  });
111
112
  it("stays silent on a learned heuristic with no overlap with the message", () => {
113
    const note = memoryRecallNote(
114
      [learnedMemory("prefer ranged reads over full file dumps")],
115
      "deploy the site",
116
      NOW,
117
    );
118
    expect(note).toBeUndefined();
119
  });
120
121
  it("drops a learned heuristic whose salience stays under the floor", () => {
122
    // One overlap hit at rock-bottom confidence: 0.1 + 0.25 < the 0.5 floor.
123
    const weak = learnedMemory("deploy carefully", { confidence: 0.1 });
124
    expect(memoryRecallNote([weak], "deploy the site", NOW)).toBeUndefined();
125
    expect(MEMORY_ATTACH_FLOOR).toBe(0.5);
126
  });
127
128
  it("ranks the memory that overlaps the message above the one that does not", () => {
129
    const note = memoryRecallNote(
130
      [
131
        userMemory("my timezone is UTC+2"),
132
        { ...userMemory("I use pnpm for installing deps"), ref: "user/fff" },
133
      ],
134
      "install the deps",
135
      NOW,
136
    );
137
    expect(note).toBeDefined();
138
    const pnpmAt = (note ?? "").indexOf("pnpm");
139
    const timezoneAt = (note ?? "").indexOf("timezone");
140
    expect(pnpmAt).toBeGreaterThan(-1);
141
    expect(timezoneAt).toBeGreaterThan(-1);
142
    expect(pnpmAt).toBeLessThan(timezoneAt);
143
  });
144
145
  it("carries at most the top-K memories and stays inside the token budget", () => {
146
    const many = Array.from({ length: 12 }, (_, index) => ({
147
      ...userMemory(`standing preference number ${index} about tooling`),
148
      ref: `user/${index}`,
149
      recordedAtMs: NOW - index * DAY,
150
    }));
151
    const note = memoryRecallNote(many, "anything at all", NOW);
152
    expect(note).toBeDefined();
153
    expect((note ?? "").split("\n- ").length - 1).toBeLessThanOrEqual(MEMORY_NOTE_LIMIT);
154
    // The budget bounds the note: 320 tokens at ~4 chars each, plus header.
155
    expect((note ?? "").length).toBeLessThan(2000);
156
  });
157
158
  it("clips a runaway memory value rather than letting it flood the turn", () => {
159
    const note = memoryRecallNote([userMemory(`x${"y".repeat(5000)}`)], "anything", NOW);
160
    expect(note).toBeDefined();
161
    expect((note ?? "").length).toBeLessThan(1000);
162
    expect(note).toContain("…");
163
  });
164
});
165
166
describe("the remember write path", () => {
167
  it("writes a user-bucket engram the rail reads back", async () => {
168
    const dir = freshDir();
169
    const memory = memoryAt(dir);
170
    const tool = rememberTool(memory);
171
    const reply = await tool.run({ fact: "I use pnpm, not npm" }, new AbortController().signal);
172
    expect(reply).toContain("Remembered");
173
174
    // A fresh handle on the same ledger sees the fact: the write is on disk,
175
    // not in the instance.
176
    const recalled = memoryAt(dir).recallable();
177
    expect(recalled).toHaveLength(1);
178
    expect(recalled[0]?.bucket).toBe("user");
179
    expect(recalled[0]?.ref.startsWith("user/")).toBe(true);
180
    expect(recalled[0]?.text).toBe("I use pnpm, not npm");
181
  });
182
183
  it("remembering the same fact twice supersedes instead of forking the chain", () => {
184
    const dir = freshDir();
185
    const memory = memoryAt(dir);
186
    expect(memory.remember("I use pnpm, not npm")).toBeDefined();
187
    expect(memory.remember("I use pnpm, not npm")).toBeDefined();
188
    const recalled = memory.recallable();
189
    expect(recalled).toHaveLength(1);
190
    expect(recalled[0]?.text).toBe("I use pnpm, not npm");
191
  });
192
193
  it("refuses credential-shaped material in words, not by throwing", async () => {
194
    const dir = freshDir();
195
    const tool = rememberTool(memoryAt(dir));
196
    const reply = await tool.run(
197
      { fact: "the token is oa_pat_abc123def456ghi789jkl012" },
198
      new AbortController().signal,
199
    );
200
    expect(reply).toContain("not stored");
201
    expect(memoryAt(dir).recallable()).toHaveLength(0);
202
  });
203
204
  it("refuses an empty fact in words", async () => {
205
    const tool = rememberTool(memoryAt(freshDir()));
206
    expect(await tool.run({ fact: "   " }, new AbortController().signal)).toContain(
207
      "Nothing to remember",
208
    );
209
    expect(await tool.run({}, new AbortController().signal)).toContain("Nothing to remember");
210
  });
211
});
212
213
describe("recall from the ledger", () => {
214
  it("surfaces a distilled heuristic as learned, with confidence and provenance", () => {
215
    const dir = freshDir();
216
    const memory = memoryAt(dir);
217
    memory.record(
218
      "heuristic/ab12cd34ef56",
219
      "install dependencies before running the tests",
220
      "synth-1#0.750",
221
      ["harvest/aaa", "harvest/bbb"],
222
    );
223
    const recalled = memory.recallable();
224
    expect(recalled).toHaveLength(1);
225
    expect(recalled[0]?.bucket).toBe("learned");
226
    expect(recalled[0]?.confidence).toBeCloseTo(0.75);
227
    expect(recalled[0]?.provenance).toEqual(["harvest/aaa", "harvest/bbb"]);
228
  });
229
230
  it("leaves harvest episodes out of the rail", () => {
231
    const dir = freshDir();
232
    const memory = memoryAt(dir);
233
    memory.record("harvest/abc123def456", "some raw finding", "child-1");
234
    expect(memory.recallable()).toHaveLength(0);
235
  });
236
237
  it("an empty ledger recalls nothing, without an error", () => {
238
    const memory = memoryAt(freshDir());
239
    expect(memory.recallable()).toEqual([]);
240
    expect(memoryRecallNote(memory.recallable(), "install the deps", NOW)).toBeUndefined();
241
  });
242
243
  it("a corrupt ledger recalls nothing, without an error", () => {
244
    const dir = freshDir();
245
    const memory = memoryAt(dir);
246
    memory.remember("I use pnpm, not npm");
247
    appendFileSync(join(dir, "engrams.jsonl"), 'not json at all\n{"half": \n');
248
    const recalled = memoryAt(dir).recallable();
249
    // The junk lines are dropped; the verified engram survives them.
250
    expect(recalled).toHaveLength(1);
251
    // A ledger that is nothing but junk is simply no memories.
252
    writeFileSync(join(dir, "engrams.jsonl"), "garbage\n{}\n[1,2]\n");
253
    expect(memoryAt(dir).recallable()).toEqual([]);
254
  });
255
256
  it.skipIf(process.getuid?.() === 0)(
257
    "an unreadable ledger recalls nothing, without an error",
258
    () => {
259
      const dir = freshDir();
260
      const memory = memoryAt(dir);
261
      memory.remember("I use pnpm, not npm");
262
      chmodSync(join(dir, "engrams.jsonl"), 0o000);
263
      try {
264
        expect(memoryAt(dir).recallable()).toEqual([]);
265
      } finally {
266
        chmodSync(join(dir, "engrams.jsonl"), 0o600);
267
      }
268
    },
269
  );
270
});
271
272
describe("end to end across the seam", () => {
273
  it("a remembered preference answers a later unrelated-sounding turn", async () => {
274
    // The acceptance scenario for #51: the reader says "remember I use pnpm,
275
    // not npm"; the model calls the remember tool once. A later "install the
276
    // deps" turn gets the memory as attached context from the rail alone.
277
    const dir = freshDir();
278
    const tool = rememberTool(memoryAt(dir));
279
    await tool.run({ fact: "I use pnpm, not npm" }, new AbortController().signal);
280
281
    const later = memoryAt(dir, NOW + 2 * DAY);
282
    const note = memoryRecallNote(later.recallable(), "install the deps", NOW + 2 * DAY);
283
    expect(note).toBeDefined();
284
    expect(note).toContain("(user note, 2d old) I use pnpm, not npm");
285
  });
286
});

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