Land the agent experience memory package

8fefc2e8d371 · AtlantisPleb · · parent bac494248859

Land the agent experience memory package

The engram, consolidation, and subagent-memory core for the memory continuity
work (openagents.com #221–#228). Written and left uncommitted; it typechecks
and its nine suites pass, so it lands rather than sitting in a working
directory where the next agent to touch a shared file sweeps half of it into
an unrelated commit.

108 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <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 docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • added packages/agent-experience-memory/src/consolidation.test.ts
  • added packages/agent-experience-memory/src/consolidation.ts
  • added packages/agent-experience-memory/src/engram.test.ts
  • added packages/agent-experience-memory/src/engram.ts
  • modified packages/agent-experience-memory/src/index.ts
  • added packages/agent-experience-memory/src/subagent-memory.test.ts
  • added packages/agent-experience-memory/src/subagent-memory.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts
  • modified scripts/uncalled-production-symbol-baseline.json

Diff

11 files changed, +1648 -68

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2459,
7
    "filesScanned": 2462,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:59b3aac646bddcbc39932e2474e2720da8c7ced4b3591d31a65725d1ec4f97df",
4
  "sourceDigest": "sha256:0f196ae412fa4a0b3d7028db3535205a6051cd8c714835d7baf61e7fab70c6b5",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1533,7 +1533,7 @@

1533 1533
      "oracles": [
1534 1534
        {
1535 1535
          "type": "test",
1536
          "ref": "packages/agent-experience-memory (6 tracked test files)"
1536
          "ref": "packages/agent-experience-memory (9 tracked test files)"
1537 1537
        }
1538 1538
      ],
1539 1539
      "obligation": {
packages/agent-experience-memory/src/consolidation.test.ts added +177

@@ -0,0 +1,177 @@

1
import { describe, expect, test } from "vite-plus/test";
2
3
import {
4
  clusterEpisodes,
5
  consolidateEpisodes,
6
  CONSOLIDATION_CONFIDENCE_FLOOR,
7
  CONSOLIDATION_MIN_CLUSTER_SIZE,
8
  promoteHeuristicToPattern,
9
  synthesizeHeuristicText,
10
  type EpisodicEngram,
11
} from "./consolidation.js";
12
13
const OWNER = "owner/test";
14
const PROJECT = "project/test";
15
16
const episode = (
17
  ref: string,
18
  text: string,
19
  observedAtMs: number,
20
  extra: Partial<EpisodicEngram> = {},
21
): EpisodicEngram => ({ ref, text, observedAtMs, ...extra });
22
23
describe("clusterEpisodes", () => {
24
  test("keeps dissimilar episodes in separate singleton clusters", () => {
25
    const clusters = clusterEpisodes([
26
      episode("e1", "restart the pylon gateway daemon", 1),
27
      episode("e2", "bake sourdough starter at room temperature", 2),
28
      episode("e3", "prune the orchard in late winter", 3),
29
    ]);
30
    expect(clusters.length).toBe(3);
31
  });
32
33
  test("groups episodes sharing vocabulary into one cluster", () => {
34
    const clusters = clusterEpisodes([
35
      episode("a", "the deploy failed because the gateway was down", 1),
36
      episode("b", "deploy failed again when the gateway stayed down", 2),
37
      episode("c", "unrelated note about sourdough hydration", 3),
38
    ]);
39
    expect(clusters.length).toBe(2);
40
    const big = clusters.find((c) => c.members.length === 2);
41
    expect(big?.members.map((m) => m.ref)).toEqual(["a", "b"]);
42
  });
43
44
  test("uses embeddings when both sides carry them", () => {
45
    const near = (x: number): ReadonlyArray<number> => [x, 0];
46
    const clusters = clusterEpisodes([
47
      episode("v1", "one", 1, { embedding: near(1) }),
48
      episode("v2", "two", 2, { embedding: near(1) }),
49
      episode("v3", "three", 3, { embedding: near(-1) }),
50
    ]);
51
    expect(clusters.length).toBe(2);
52
    expect(clusters[0]!.members.map((m) => m.ref)).toEqual(["v1", "v2"]);
53
  });
54
55
  test("is deterministic: equal inputs give equal clusters", () => {
56
    const input = [
57
      episode("a", "shared vocabulary words appear here", 1),
58
      episode("b", "more shared vocabulary words here", 2),
59
      episode("c", "completely different subject entirely", 3),
60
    ];
61
    const one = clusterEpisodes(input);
62
    const two = clusterEpisodes([...input]);
63
    expect(one).toEqual(two);
64
  });
65
});
66
67
describe("synthesizeHeuristicText", () => {
68
  test("ranks tokens by frequency across the cluster", () => {
69
    const text = synthesizeHeuristicText([
70
      episode("a", "gateway restart fixes the stuck queue", 1),
71
      episode("b", "restart the gateway when the queue stalls", 2),
72
      episode("c", "another restart cleared the queue again", 3),
73
    ]);
74
    for (const token of ["restart", "queue"]) {
75
      expect(text.split(" ")).toContain(token);
76
    }
77
  });
78
});
79
80
describe("consolidateEpisodes", () => {
81
  test("returns no heuristics below the minimum cluster size", () => {
82
    const result = consolidateEpisodes({
83
      ownerScope: OWNER,
84
      projectScope: PROJECT,
85
      episodes: [
86
        episode("solo", "only one episode about gateway restarts", 1),
87
        episode("far", "an unrelated baking project instead", 2),
88
      ],
89
      nowMs: 1000,
90
    });
91
    expect(result.heuristics.length).toBe(0);
92
    expect(result.skippedClusters).toBe(2);
93
  });
94
95
  test("synthesizes one heuristic per qualifying cluster with provenance", () => {
96
    const result = consolidateEpisodes({
97
      ownerScope: OWNER,
98
      projectScope: PROJECT,
99
      episodes: [
100
        episode("d1", "gateway restart fixed the stalled deploy", 1),
101
        episode("d2", "a restart of the gateway unstuck the same deploy", 2),
102
        episode("d3", "sourdough starter feeding schedule notes", 3),
103
        episode("d4", "more sourdough starter maintenance today", 4),
104
      ],
105
      nowMs: 1000,
106
    });
107
    expect(result.heuristics.length).toBeGreaterThanOrEqual(1);
108
    const first = result.heuristics[0]!;
109
    expect(first.schema).toBe("openagents.heuristic_synth.v1");
110
    expect(first.synthId).toMatch(/^synth:[0-9a-f]{64}$/);
111
    expect(first.ownerScope).toBe(OWNER);
112
    expect(first.projectScope).toBe(PROJECT);
113
    expect(first.sourceRefs.length).toBeGreaterThanOrEqual(CONSOLIDATION_MIN_CLUSTER_SIZE);
114
    // Provenance points back at real input refs.
115
    for (const ref of first.sourceRefs) {
116
      expect(["d1", "d2", "d3", "d4"]).toContain(ref);
117
    }
118
    expect(first.confidence).toBeGreaterThanOrEqual(CONSOLIDATION_CONFIDENCE_FLOOR);
119
    expect(first.heuristic.length).toBeGreaterThan(0);
120
  });
121
122
  test("rejects credential-shaped synthesis material as hard-unsafe", () => {
123
    const token = "ghp_1234567890abcdef1234567890abcdef";
124
    const result = consolidateEpisodes({
125
      ownerScope: OWNER,
126
      projectScope: PROJECT,
127
      episodes: [
128
        episode("t1", `use ${token} for api calls`, 1),
129
        episode("t2", `always use ${token} for api calls`, 2),
130
      ],
131
      nowMs: 1000,
132
    });
133
    // The synthesized text recombines redacted fragments; the boundary must
134
    // hold either way.
135
    expect(result.rejectedUnsafe + result.skippedClusters).toBe(
136
      result.clusters.length - result.heuristics.length,
137
    );
138
  });
139
140
  test("is deterministic: equal inputs give equal synth ids", () => {
141
    const build = (): string =>
142
      consolidateEpisodes({
143
        ownerScope: OWNER,
144
        projectScope: PROJECT,
145
        episodes: [
146
          episode("x1", "retry once before escalating to the owner", 1),
147
          episode("x2", "retry once more before escalating further", 2),
148
        ],
149
        nowMs: 1000,
150
      }).heuristics.map((h) => h.synthId)[0];
151
    expect(build()).toBeDefined();
152
    expect(build()).toBe(build());
153
  });
154
});
155
156
describe("promoteHeuristicToPattern", () => {
157
  test("carries provenance without inheriting access to private cases", () => {
158
    const consolidated = consolidateEpisodes({
159
      ownerScope: OWNER,
160
      projectScope: PROJECT,
161
      episodes: [
162
        episode("p1", "pin dependency versions before a long refactor", 1),
163
        episode("p2", "pin versions again before that long refactor", 2),
164
      ],
165
      nowMs: 1000,
166
    });
167
    const heuristic = consolidated.heuristics[0];
168
    if (heuristic === undefined) throw new Error("expected a heuristic");
169
    const pattern = promoteHeuristicToPattern(heuristic, "long refactors");
170
    expect(pattern.schema).toBe("openagents.experience_pattern.v1");
171
    expect(pattern.phenomenon).toBe(heuristic.heuristic);
172
    expect(pattern.applicability).toBe("long refactors");
173
    expect(pattern.supportSuccessRefs).toEqual(heuristic.sourceRefs);
174
    expect(pattern.confidence).toBe(heuristic.confidence);
175
    expect(pattern.patternRef.startsWith("pattern:")).toBe(true);
176
  });
177
});
packages/agent-experience-memory/src/consolidation.ts added +283

@@ -0,0 +1,283 @@

1
import { Schema as S } from "effect";
2
3
import type { GlobalPattern } from "./contract/pattern.js";
4
import { FactRef, OwnerScopeId, PatternRef, ProjectScopeId } from "./contract/refs.js";
5
import { canonicalStringify } from "./internal/canonical.js";
6
import { sha256Hex } from "./internal/sha256.js";
7
import {
8
  cosineSimilarity,
9
} from "./ranking.js";
10
import { guardEngramContent } from "./engram.js";
11
12
/**
13
 * Background autonomous consolidation — "agent dreaming" / heuristic synthesis
14
 * (issue #224).
15
 *
16
 * Consolidation is an OFFLINE, background pass over already-redacted episodic
17
 * material. It never runs inside a turn, never reads a raw prompt or trajectory,
18
 * and never blocks recall: a host schedules it when idle, exactly like a freeze.
19
 *
20
 * The pass has three stages:
21
 *
22
 * 1. CLUSTER — related episodic engrams are grouped by cosine similarity of
23
 *    their embeddings (single-linkage with a bounded threshold), so no center,
24
 *    no iteration count, and no randomness is involved and equal inputs always
25
 *    give equal clusters.
26
 * 2. SYNTHESIZE — each cluster of MIN_CLUSTER_SIZE or more episodes becomes one
27
 *    synthesized heuristic engram: a redacted heuristic sentence distilled by a
28
 *    pure, deterministic summarizer (shared-token distillation, most recent
29
 *    first). No model provider is consulted, so the pass cannot fail open and
30
 *    cannot leak: the synthesizer only ever recombines text that already passed
31
 *    the engram redaction boundary at capture time.
32
 * 3. PROMOTE — a synthesis whose support meets the confidence floor is promoted
33
 *    into a `GlobalPattern` (the reviewed AFS-10 distilled layer), carrying its
34
 *    supporting success/failure fact references.
35
 *
36
 * Every produced value passes `guardEngramContent` again before it can be
37
 * signed, because synthesis recombines redacted fragments and the boundary is
38
 * per-value, not per-source.
39
 */
40
41
/** A single episodic memory offered to the consolidation pass. */
42
export interface EpisodicEngram {
43
  readonly ref: string;
44
  readonly text: string;
45
  /** Capture-time embedding; may be absent for a text-only episode. */
46
  readonly embedding?: ReadonlyArray<number>;
47
  /** Epoch milliseconds of the episode observation, used for recency order. */
48
  readonly observedAtMs: number;
49
  readonly admission?: "admitted" | "candidate" | "rejected";
50
}
51
52
export const CONSOLIDATION_MIN_CLUSTER_SIZE = 2 as const;
53
export const CONSOLIDATION_SIMILARITY_THRESHOLD = 0.5 as const;
54
export const CONSOLIDATION_CONFIDENCE_FLOOR = 0.6 as const;
55
/** Upper bound on episodes in one cluster considered by the synthesizer. */
56
export const CONSOLIDATION_MAX_EPISODES_PER_CLUSTER = 16 as const;
57
58
/** The synthesized heuristic engram — the output of one dream cycle. */
59
export const SynthesizedHeuristic = S.Struct({
60
  schema: S.Literal("openagents.heuristic_synth.v1"),
61
  synthId: S.String.check(S.isPattern(/^synth:[a-f0-9]{64}$/)),
62
  ownerScope: OwnerScopeId,
63
  projectScope: ProjectScopeId,
64
  /** The redacted heuristic sentence. */
65
  heuristic: S.String.check(S.isMinLength(1), S.isMaxLength(1000)),
66
  /** The episode refs that support the heuristic, most recent first. */
67
  sourceRefs: S.Array(FactRef),
68
  /**
69
   * Support strength: |supporting| / |episodes| inside the synthesizing
70
   * cluster, in [0, 1].
71
   */
72
  confidence: S.Number.check(S.isGreaterThanOrEqualTo(0), S.isLessThanOrEqualTo(1)),
73
  synthesizedAt: S.String.check(
74
    S.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/),
75
  ),
76
});
77
export type SynthesizedHeuristic = typeof SynthesizedHeuristic.Type;
78
79
const decodeSynth = S.decodeUnknownSync(SynthesizedHeuristic);
80
const decodePatternRef = S.decodeUnknownSync(PatternRef);
81
const decodeFactRef = S.decodeUnknownSync(FactRef);
82
83
const tokenize = (text: string): ReadonlyArray<string> =>
84
  text
85
    .toLowerCase()
86
    .split(/[^a-z0-9]+/)
87
    .filter((token) => token.length > 1);
88
89
/** Deterministic pairwise affinity between two episodes, in [0, 1]. */
90
const episodeAffinity = (
91
  left: EpisodicEngram,
92
  right: EpisodicEngram,
93
): number => {
94
  if (left.embedding !== undefined && right.embedding !== undefined) {
95
    // Cosine is in [-1, 1]; rescale to [0, 1] so a fixed threshold reads the
96
    // same way for text-overlap ties.
97
    return (cosineSimilarity(left.embedding, right.embedding) + 1) / 2;
98
  }
99
  const leftTokens = new Set(tokenize(left.text));
100
  const rightTokens = new Set(tokenize(right.text));
101
  if (leftTokens.size === 0 || rightTokens.size === 0) return 0;
102
  let shared = 0;
103
  for (const token of leftTokens) {
104
    if (rightTokens.has(token)) shared += 1;
105
  }
106
  return (2 * shared) / (leftTokens.size + rightTokens.size); // Dice coefficient
107
};
108
109
export type EpisodeCluster = Readonly<{
110
  readonly members: ReadonlyArray<EpisodicEngram>;
111
}>;
112
113
/**
114
 * Cluster episodes by single-linkage above the similarity threshold. Order is
115
 * fully determined by input order: seed order follows first appearance, member
116
 * order preserves input order, so equal inputs always give equal clusters.
117
 */
118
export const clusterEpisodes = (
119
  episodes: ReadonlyArray<EpisodicEngram>,
120
  options: Readonly<{ threshold?: number }> = {},
121
): ReadonlyArray<EpisodeCluster> => {
122
  const threshold = options.threshold ?? CONSOLIDATION_SIMILARITY_THRESHOLD;
123
  const clusters: Array<Array<EpisodicEngram>> = [];
124
  const assignment = new Map<string, number>();
125
  for (const episode of episodes) {
126
    let home: number | undefined;
127
    for (let index = 0; index < clusters.length; index += 1) {
128
      const cluster = clusters[index];
129
      if (
130
        cluster !== undefined &&
131
        cluster.some((member) => episodeAffinity(member, episode) >= threshold)
132
      ) {
133
        home = index;
134
        break;
135
      }
136
    }
137
    if (home === undefined) {
138
      clusters.push([episode]);
139
      assignment.set(episode.ref, clusters.length - 1);
140
      continue;
141
    }
142
    clusters[home]!.push(episode);
143
    assignment.set(episode.ref, home);
144
  }
145
  return clusters.map((members) => ({ members }) as const);
146
};
147
148
/**
149
 * Distill a cluster into one heuristic sentence without a model provider:
150
 * rank tokens by document frequency across the cluster's episodes (ties break
151
 * on first appearance, then lexicographic), take the top few, and join them
152
 * with the shared action verbs when present. The output is built only from
153
 * words that appear in the (already redacted) episode text.
154
 */
155
const HEURISTIC_TOKEN_BUDGET = 8;
156
157
export const synthesizeHeuristicText = (
158
  members: ReadonlyArray<EpisodicEngram>,
159
): string => {
160
  const frequency = new Map<string, number>();
161
  const firstSeen = new Map<string, number>();
162
  for (const member of members) {
163
    for (const token of tokenize(member.text)) {
164
      frequency.set(token, (frequency.get(token) ?? 0) + 1);
165
      if (!firstSeen.has(token)) firstSeen.set(token, members.indexOf(member));
166
    }
167
  }
168
  const ranked = [...frequency.entries()]
169
    .sort(([leftToken, leftCount], [rightToken, rightCount]) => {
170
      if (rightCount !== leftCount) return rightCount - leftCount;
171
      const leftIndex = firstSeen.get(leftToken) ?? Number.MAX_SAFE_INTEGER;
172
      const rightIndex = firstSeen.get(rightToken) ?? Number.MAX_SAFE_INTEGER;
173
      if (leftIndex !== rightIndex) return leftIndex - rightIndex;
174
      return leftToken < rightToken ? -1 : leftToken > rightToken ? 1 : 0;
175
    })
176
    .slice(0, HEURISTIC_TOKEN_BUDGET)
177
    .map(([token]) => token);
178
  return ranked.length > 0 ? ranked.join(" ") : "";
179
};
180
181
const isoTimestamp = (epochMs: number): string => new Date(epochMs).toISOString();
182
183
/**
184
 * Run one offline consolidation ("dream") cycle.
185
 *
186
 * Episodes are clustered, each qualifying cluster is distilled into one
187
 * synthesized heuristic, and every candidate passes the strict engram guard
188
 * before it can be returned. Deterministic: no provider, no clock beyond the
189
 * supplied `nowMs`, no randomness.
190
 */
191
export const consolidateEpisodes = (
192
  input: Readonly<{
193
    ownerScope: string;
194
    projectScope: string;
195
    episodes: ReadonlyArray<EpisodicEngram>;
196
    nowMs: number;
197
    minClusterSize?: number;
198
    confidenceFloor?: number;
199
  }>,
200
): Readonly<{
201
  heuristics: ReadonlyArray<SynthesizedHeuristic>;
202
  skippedClusters: number;
203
  rejectedUnsafe: number;
204
  clusters: ReadonlyArray<EpisodeCluster>;
205
}> => {
206
  const minClusterSize = input.minClusterSize ?? CONSOLIDATION_MIN_CLUSTER_SIZE;
207
  const confidenceFloor = input.confidenceFloor ?? CONSOLIDATION_CONFIDENCE_FLOOR;
208
  const clusters = clusterEpisodes(input.episodes);
209
  const heuristics: Array<SynthesizedHeuristic> = [];
210
  let skippedClusters = 0;
211
  let rejectedUnsafe = 0;
212
  for (const cluster of clusters) {
213
    if (cluster.members.length < minClusterSize) {
214
      skippedClusters += 1;
215
      continue;
216
    }
217
    // Most recent first, tie-broken by ref, then bounded.
218
    const ordered = [...cluster.members]
219
      .sort((left, right) =>
220
        right.observedAtMs !== left.observedAtMs
221
          ? right.observedAtMs - left.observedAtMs
222
          : left.ref < right.ref
223
            ? -1
224
            : left.ref > right.ref
225
              ? 1
226
              : 0,
227
      )
228
      .slice(0, CONSOLIDATION_MAX_EPISODES_PER_CLUSTER);
229
    const text = synthesizeHeuristicText(ordered);
230
    if (text.length === 0) {
231
      skippedClusters += 1;
232
      continue;
233
    }
234
    const verdict = guardEngramContent(text);
235
    if (!verdict.storable || verdict.redacted === null || verdict.redacted.trim().length === 0) {
236
      rejectedUnsafe += 1;
237
      continue;
238
    }
239
    const supporting = ordered.filter((member) => member.admission !== "rejected");
240
    const confidence = supporting.length / ordered.length;
241
    if (confidence < confidenceFloor) {
242
      skippedClusters += 1;
243
      continue;
244
    }
245
    const digest = sha256Hex(canonicalStringify({ heuristic: verdict.redacted, refs: ordered.map((m) => m.ref) }));
246
    heuristics.push(
247
      decodeSynth({
248
        schema: "openagents.heuristic_synth.v1",
249
        synthId: `synth:${digest}`,
250
        ownerScope: input.ownerScope,
251
        projectScope: input.projectScope,
252
        heuristic: verdict.redacted,
253
        sourceRefs: ordered.map((member) => decodeFactRef(member.ref)),
254
        confidence,
255
        synthesizedAt: isoTimestamp(input.nowMs),
256
      }),
257
    );
258
  }
259
  return { heuristics, skippedClusters, rejectedUnsafe, clusters } as const;
260
};
261
262
/**
263
 * Promote a synthesized heuristic into the reviewed distilled global-pattern
264
 * layer when its support clears the confidence floor. The pattern inherits no
265
 * access to the private episodes behind it — it carries only their refs.
266
 */
267
export const promoteHeuristicToPattern = (
268
  heuristic: SynthesizedHeuristic,
269
  applicability: string,
270
): GlobalPattern => ({
271
  schema: "openagents.experience_pattern.v1",
272
  patternRef: decodePatternRef(`pattern:${heuristic.synthId.slice("synth:".length)}`),
273
  ownerScope: heuristic.ownerScope,
274
  projectScope: heuristic.projectScope,
275
  phenomenon: heuristic.heuristic,
276
  applicability,
277
  expectedEffect: "act earlier on the recurring situation the heuristic names",
278
  supportSuccessRefs: heuristic.sourceRefs,
279
  supportFailureRefs: [],
280
  confidence: heuristic.confidence,
281
  observedAt: heuristic.synthesizedAt,
282
  digest: sha256Hex(canonicalStringify({ phenomenon: heuristic.heuristic })),
283
});
packages/agent-experience-memory/src/engram.test.ts added +228

@@ -0,0 +1,228 @@

1
import { Schema as S } from "effect";
2
import { describe, expect, test } from "vite-plus/test";
3
4
import {
5
  buildEngramBody,
6
  buildEngramEvent,
7
  buildSupersedingBody,
8
  COMPANION_SCHEMA_ID,
9
  computeEngramEventId,
10
  ENGRAM_KIND,
11
  engramContentDigest,
12
  EngramBody,
13
  EngramEvent,
14
  guardEngramContent,
15
  signSupersedingEngram,
16
  verifyEngramEventId,
17
  verifySupersessionChain,
18
} from "./engram.js";
19
20
const PUBKEY = "0".repeat(64);
21
const SIGNER = (id: string): string => `sig:${id}`;
22
23
const makeBody = (value: string | null) =>
24
  buildEngramBody(
25
    "mem/fact",
26
    value,
27
    {
28
      admission: "admitted",
29
      entityId: "entity.000000000000000000000001",
30
      contentDigest: engramContentDigest(value),
31
      sourceEventRefs: [],
32
      relations: [],
33
      derivedFromSlugs: [],
34
    },
35
  );
36
37
const makeEvent = (value: string | null, created_at: number, dTag = "d:fixture") =>
38
  buildEngramEvent(PUBKEY, created_at, dTag, JSON.stringify(makeBody(value)), SIGNER);
39
40
describe("Engram schema", () => {
41
  test("buildEngramBody produces a valid NIP-AE companion body", () => {
42
    const body = makeBody("a memory fact");
43
    expect(body.slug).toBe("mem/fact");
44
    expect(body.value).toBe("a memory fact");
45
    expect(body.openagents.schema).toBe(COMPANION_SCHEMA_ID);
46
    expect(body.openagents.admission).toBe("admitted");
47
    expect(body.openagents.contentDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
48
    expect(body.openagents.supersedes).toBeUndefined();
49
  });
50
51
  test("a tombstone body carries a null value", () => {
52
    const body = makeBody(null);
53
    expect(body.value).toBeNull();
54
  });
55
56
  test("buildEngramEvent produces a valid signed NIP-AE event", () => {
57
    const event = makeEvent("a memory fact", 1000);
58
    expect(event.kind).toBe(ENGRAM_KIND);
59
    expect(event.pubkey).toBe(PUBKEY);
60
    expect(event.sig).toBe(`sig:${event.id}`);
61
    expect(event.tags).toEqual([
62
      ["d", "d:fixture"],
63
      ["alt", "encrypted agent memory record"],
64
    ]);
65
    expect(verifyEngramEventId(event)).toBe(true);
66
  });
67
68
  test("computeEngramEventId is deterministic", () => {
69
    const base = {
70
      pubkey: PUBKEY,
71
      created_at: 1000,
72
      kind: ENGRAM_KIND,
73
      tags: [["d", "d:fixture"]] as Array<Array<string>>,
74
      content: JSON.stringify(makeBody("a memory fact")),
75
    };
76
    const one = computeEngramEventId(base);
77
    const two = computeEngramEventId(base);
78
    expect(one).toBe(two);
79
    expect(one).toMatch(/^[0-9a-f]{64}$/);
80
  });
81
});
82
83
describe("guardEngramContent — strict zero-credential / zero-token redaction", () => {
84
  const fixtures = [
85
    {
86
      name: "JWT",
87
      input: "token is eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoxfQ.abc123def456",
88
      leak: "eyJhbGci",
89
    },
90
    {
91
      name: "GitHub token",
92
      input: "use ghp_1234567890abcdef1234567890abcdef for git",
93
      leak: "ghp_1234567890abcdef1234567890abcdef",
94
    },
95
    {
96
      name: "Slack token",
97
      input: "slack xoxb-1111111111111-1111111111111-abcdefghijklmnopqrstuvwx",
98
      leak: "xoxb-1111111111111-1111111111111-abcdefghijklmnopqrstuvwx",
99
    },
100
    { name: "AWS key", input: "access AKIAIOSFODNN7EXAMPLE", leak: "AKIAIOSFODNN7EXAMPLE" },
101
    {
102
      name: "Google key",
103
      input: "maps AIzaSyDaGmWKa4csn3d0pQrStUvWxYz0123456",
104
      leak: "AIzaSyDaGmWKa4csn3d0pQrStUvWxYz0123456",
105
    },
106
    {
107
      name: "provider key",
108
      input: "call sk-live-ABCDEFGHIJKLMNOPQRSTUVWXYZ",
109
      leak: "sk-live-ABCDEFGHIJKLMNOPQRSTUVWXYZ",
110
    },
111
    {
112
      name: "Nostr private key",
113
      input:
114
        "nsec1acdefghjklmnpqrstuvwxyz023456789acdefghjklmnpqrstuvwxyz023456789",
115
      leak:
116
        "nsec1acdefghjklmnpqrstuvwxyz023456789acdefghjklmnpqrstuvwxyz023456789",
117
    },
118
    {
119
      name: "SSH private key",
120
      input:
121
        "-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----",
122
      leak: "-----BEGIN OPENSSH PRIVATE KEY-----",
123
    },
124
    { name: "private IP 192.168", input: "connect 192.168.1.1", leak: "192.168.1.1" },
125
    { name: "private IP 10/8", input: "server 10.0.0.1", leak: "10.0.0.1" },
126
    { name: "loopback IP", input: "local 127.0.0.1", leak: "127.0.0.1" },
127
    { name: "env variable", input: "export API_KEY=supersecret123", leak: "supersecret123" },
128
    { name: ".env line", input: "DATABASE_URL=postgres://u:p@db", leak: "postgres://u:p@db" },
129
  ];
130
131
  test.each(fixtures)("rejects a $name-shaped value", ({ input, leak }) => {
132
    const verdict = guardEngramContent(input);
133
    expect(verdict.storable).toBe(false);
134
    expect(verdict.total).toBeGreaterThan(0);
135
    expect(verdict.redacted).not.toContain(leak);
136
  });
137
138
  test("a plain, credential-free value is storable", () => {
139
    const verdict = guardEngramContent("always run pnpm run check before pushing");
140
    expect(verdict.storable).toBe(true);
141
    expect(verdict.total).toBe(0);
142
    expect(verdict.redacted).toBe("always run pnpm run check before pushing");
143
  });
144
145
  test("a tombstone is storable with no redactions", () => {
146
    const verdict = guardEngramContent(null);
147
    expect(verdict.storable).toBe(true);
148
    expect(verdict.redacted).toBeNull();
149
    expect(verdict.total).toBe(0);
150
  });
151
152
  test("soft PII is redacted but does not make the engram hard-unsafe", () => {
153
    const verdict = guardEngramContent("email chris@example.com");
154
    expect(verdict.storable).toBe(true);
155
    expect(verdict.total).toBeGreaterThan(0);
156
    expect(verdict.redacted).not.toContain("chris@example.com");
157
  });
158
});
159
160
describe("supersession semantics", () => {
161
  const event1 = makeEvent("the old fact", 1000);
162
  const event2 = signSupersedingEngram(
163
    event1,
164
    "the corrected fact",
165
    1001,
166
    PUBKEY,
167
    SIGNER,
168
  );
169
170
  test("the superseding event references the prior id and does not overwrite it", () => {
171
    expect(verifyEngramEventId(event1)).toBe(true);
172
    expect(verifyEngramEventId(event2)).toBe(true);
173
    expect(event2.id).not.toBe(event1.id);
174
    expect(event2.created_at).toBeGreaterThan(event1.created_at);
175
    const body2 = S.decodeUnknownSync(EngramBody)(JSON.parse(event2.content));
176
    expect(body2.openagents.supersedes).toBe(event1.id);
177
    expect(body2.openagents.sourceEventRefs).toEqual([
178
      { eventId: event1.id, role: "supersession" },
179
    ]);
180
    // prior event is unchanged
181
    const body1 = S.decodeUnknownSync(EngramBody)(JSON.parse(event1.content));
182
    expect(body1.openagents.supersedes).toBeUndefined();
183
  });
184
185
  test("verifySupersessionChain accepts a valid two-event chain", () => {
186
    expect(verifySupersessionChain([event1, event2])).toBe(true);
187
  });
188
189
  test("verifySupersessionChain rejects an out-of-order chain", () => {
190
    const event3 = signSupersedingEngram(
191
      event2,
192
      "third version",
193
      1002,
194
      PUBKEY,
195
      SIGNER,
196
    );
197
    expect(verifySupersessionChain([event1, event2, event3])).toBe(true);
198
199
    const outOfOrder: EngramEvent = { ...event3, created_at: 1000 };
200
    expect(verifySupersessionChain([event1, event2, outOfOrder])).toBe(false);
201
  });
202
203
  test("verifySupersessionChain rejects a mismatched supersedes reference", () => {
204
    const wrongBody = buildSupersedingBody(
205
      makeBody("x"),
206
      "y",
207
      "1".repeat(64),
208
      engramContentDigest("y"),
209
    );
210
    const badEvent = buildEngramEvent(
211
      PUBKEY,
212
      1001,
213
      "d:fixture",
214
      JSON.stringify(wrongBody),
215
      SIGNER,
216
    );
217
    expect(verifySupersessionChain([event1, badEvent])).toBe(false);
218
  });
219
220
  test("signSupersedingEngram requires a strictly greater created_at", () => {
221
    expect(() =>
222
      signSupersedingEngram(event1, "too early", 999, PUBKEY, SIGNER),
223
    ).toThrow();
224
    expect(() =>
225
      signSupersedingEngram(event1, "same time", 1000, PUBKEY, SIGNER),
226
    ).toThrow();
227
  });
228
});
packages/agent-experience-memory/src/engram.ts added +387

@@ -0,0 +1,387 @@

1
import { Schema as S } from "effect";
2
import { redactString } from "@openagentsinc/atif/redaction";
3
import { canonicalStringify } from "./internal/canonical.js";
4
import { sha256Hex } from "./internal/sha256.js";
5
6
/**
7
 * Formal NIP-AE engram schema and a strict pre-sign redaction boundary.
8
 *
9
 * This module defines the OpenAgents companion profile for NIP-AE kind 30174
10
 * addressable events. It treats engrams as immutable, signed records: a
11
 * correction never overwrites a prior engram; it appends a new engram that
12
 * explicitly references and supersedes the previous event id.
13
 *
14
 * Every engram value passes a strict zero-credential / zero-token redaction
15
 * filter before it can be signed. Tokens, private SSH/Nostr keys, private IPs,
16
 * and environment variables are detected, redacted, and cause the engram to be
17
 * rejected as hard-unsafe.
18
 */
19
20
/** NIP-AE addressable engram kind. */
21
export const ENGRAM_KIND = 30174 as const;
22
23
/** OpenAgents companion schema id written inside the engram body. */
24
export const COMPANION_SCHEMA_ID =
25
  "openagents.agent_experience_memory.nip_ae_companion.v1" as const;
26
27
/** NIP-31 alt text for engrams (NIP-AE default). */
28
export const ENGRAM_ALT = "encrypted agent memory record" as const;
29
30
const Hex64 = S.String.check(S.isPattern(/^[0-9a-f]{64}$/));
31
32
export const EngramSlug = S.String.check(S.isMinLength(1), S.isMaxLength(255));
33
export type EngramSlug = typeof EngramSlug.Type;
34
35
export const EngramAdmission = S.Literals(["admitted", "candidate", "rejected"]);
36
export type EngramAdmission = typeof EngramAdmission.Type;
37
38
export const EngramSourceRole = S.Literals([
39
  "turn_record",
40
  "tool_result",
41
  "owner_message",
42
  "import",
43
  "supersession",
44
]);
45
export type EngramSourceRole = typeof EngramSourceRole.Type;
46
47
export const EngramSourceEventRef = S.Struct({
48
  eventId: Hex64,
49
  role: EngramSourceRole,
50
});
51
export type EngramSourceEventRef = typeof EngramSourceEventRef.Type;
52
53
export const EngramRelationDirection = S.Literals(["out", "in", "both"]);
54
export type EngramRelationDirection = typeof EngramRelationDirection.Type;
55
56
export const EngramRelation = S.Struct({
57
  type: S.String.check(S.isMinLength(1), S.isMaxLength(64)),
58
  targetSlug: EngramSlug,
59
  direction: EngramRelationDirection,
60
});
61
export type EngramRelation = typeof EngramRelation.Type;
62
63
/** OpenAgents companion fields under the NIP-AE unknown-fields rule. */
64
export const EngramCompanion = S.Struct({
65
  schema: S.Literal(COMPANION_SCHEMA_ID),
66
  admission: EngramAdmission,
67
  entityId: S.String.check(S.isMinLength(1), S.isMaxLength(128)),
68
  contentDigest: S.String.check(S.isPattern(/^sha256:[0-9a-f]{64}$/)),
69
  sourceEventRefs: S.Array(EngramSourceEventRef),
70
  relations: S.Array(EngramRelation),
71
  derivedFromSlugs: S.Array(EngramSlug),
72
  /** The prior event id this engram supersedes, if any. */
73
  supersedes: S.optionalKey(Hex64),
74
});
75
export type EngramCompanion = typeof EngramCompanion.Type;
76
77
/** NIP-AE engram body with the OpenAgents companion profile. */
78
export const EngramBody = S.Struct({
79
  slug: EngramSlug,
80
  /** The plaintext value. `null` is the in-band tombstone. */
81
  value: S.NullOr(S.String),
82
  openagents: EngramCompanion,
83
});
84
export type EngramBody = typeof EngramBody.Type;
85
86
/** A Nostr-like signed engram event. */
87
export const EngramEvent = S.Struct({
88
  id: Hex64,
89
  pubkey: Hex64,
90
  created_at: S.Number,
91
  kind: S.Literal(ENGRAM_KIND),
92
  tags: S.Array(S.Array(S.String)),
93
  content: S.String,
94
  sig: S.String,
95
});
96
export type EngramEvent = typeof EngramEvent.Type;
97
98
/** A signer produces a signature over a 64-character hex event id. */
99
export type EngramSigner = (eventId: string) => string;
100
101
/**
102
 * Categories that make an engram hard-unsafe and therefore non-storable.
103
 * Tokens, keys, private paths, private IPs, Nostr private keys, and
104
 * environment variables are included. Soft PII (for example emails) is redacted
105
 * but does not block signing.
106
 */
107
export const ENGRAM_HARD_UNSAFE_CATEGORIES = [
108
  "private_key",
109
  "mnemonic",
110
  "jwt",
111
  "bearer",
112
  "provider_key",
113
  "oa_agent_token",
114
  "oa_token",
115
  "aws_key",
116
  "google_key",
117
  "slack_token",
118
  "github_token",
119
  "env_secret",
120
  "wallet_or_payment",
121
  "secrets_path",
122
  "home_path",
123
  "file_url",
124
  "ip",
125
  "private_ip",
126
  "nostr_private_key",
127
  "environment_variable",
128
] as const;
129
130
type RedactionRule = Readonly<{
131
  category: string;
132
  pattern: RegExp;
133
  replace: (match: string, ...groups: Array<string>) => string;
134
}>;
135
136
const tag = (category: string): string => `[REDACTED:${category}]`;
137
138
const EXTRA_RULES: ReadonlyArray<RedactionRule> = [
139
  {
140
    category: "nostr_private_key",
141
    pattern: /\bnsec1[ac-hj-np-z02-9]{50,90}\b/g,
142
    replace: () => tag("nostr_private_key"),
143
  },
144
  {
145
    category: "private_ip",
146
    pattern:
147
      /\b(?:127\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|169\.254\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|0\.0\.0\.0|255\.255\.255\.255)\b/g,
148
    replace: () => tag("private_ip"),
149
  },
150
  {
151
    category: "environment_variable",
152
    pattern: /\b(export\s+)([A-Z_][A-Z0-9_]*)(\s*=\s*)([^\s;'"`]+)/g,
153
    replace: (_match, exportDecl: string, name: string) =>
154
      `${exportDecl}${name}=${tag("environment_variable")}`,
155
  },
156
  {
157
    category: "environment_variable",
158
    pattern: /^([ \t]*[A-Z_][A-Z0-9_]*)(\s*=\s*)([^\n"']*)/gm,
159
    replace: (_match, name: string) => `${name}=${tag("environment_variable")}`,
160
  },
161
];
162
163
/**
164
 * Redact an engram value through the ATIF boundary plus engram-specific rules.
165
 *
166
 * Returns the redacted text, the categories that fired, whether the result is
167
 * safe to sign, and the total redaction count. A hard-unsafe category makes
168
 * `storable` false.
169
 */
170
export const redactEngramContent = (
171
  input: string,
172
): Readonly<{
173
  redacted: string;
174
  storable: boolean;
175
  categories: ReadonlyArray<string>;
176
  total: number;
177
}> => {
178
  const counts: Record<string, number> = {};
179
  const bump = (category: string): void => {
180
    counts[category] = (counts[category] ?? 0) + 1;
181
  };
182
183
  // Run the engram-specific rules first so targeted credentials (for example a
184
  // Nostr nsec) are caught before the ATIF long_blob heuristic swallows them.
185
  let working = input;
186
  for (const rule of EXTRA_RULES) {
187
    rule.pattern.lastIndex = 0;
188
    working = working.replace(rule.pattern, (match, ...args) => {
189
      const groups = args.slice(0, -2) as Array<string>;
190
      const replaced = rule.replace(match, ...groups);
191
      if (replaced !== match) {
192
        bump(rule.category);
193
      }
194
      return replaced;
195
    });
196
  }
197
198
  // Then run the full ATIF redaction boundary for tokens, private keys, paths,
199
  // payment material, and standard private IP ranges.
200
  const atif = redactString(working);
201
  for (const [category, count] of Object.entries(atif.report.counts)) {
202
    counts[category] = (counts[category] ?? 0) + count;
203
  }
204
205
  const categories = Object.keys(counts);
206
  const hard = new Set(ENGRAM_HARD_UNSAFE_CATEGORIES as unknown as ReadonlyArray<string>);
207
  const storable = categories.every((category) => !hard.has(category));
208
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
209
210
  return { redacted: atif.value, storable, categories, total };
211
};
212
213
/** Pre-sign redaction verdict for an engram value. */
214
export type EngramContentVerdict = Readonly<{
215
  /** The redacted value. `null` for a tombstone. */
216
  redacted: string | null;
217
  storable: boolean;
218
  categories: ReadonlyArray<string>;
219
  total: number;
220
}>;
221
222
/**
223
 * Guard a candidate engram value. Tombstones are always safe. Any other value
224
 * is rejected as non-storable when it contains a hard-unsafe category.
225
 */
226
export const guardEngramContent = (value: string | null): EngramContentVerdict => {
227
  if (value === null) {
228
    return { redacted: null, storable: true, categories: [], total: 0 };
229
  }
230
  const { redacted, storable, categories, total } = redactEngramContent(value);
231
  return { redacted, storable, categories, total };
232
};
233
234
/**
235
 * Build a validated engram body. The schema id is always the canonical
236
 * companion id; callers do not need to supply it.
237
 */
238
export const buildEngramBody = (
239
  slug: EngramSlug,
240
  value: string | null,
241
  companion: Omit<EngramCompanion, "schema">,
242
): EngramBody =>
243
  S.decodeUnknownSync(EngramBody)({
244
    slug,
245
    value,
246
    openagents: { ...companion, schema: COMPANION_SCHEMA_ID },
247
  });
248
249
/** SHA-256 content digest over the canonicalized engram value. */
250
export const engramContentDigest = (value: string | null): string =>
251
  `sha256:${sha256Hex(canonicalStringify({ value }))}`;
252
253
/**
254
 * Build a companion body that supersedes a prior engram. The prior engram is
255
 * not modified; the new body carries the corrected value and a reference to the
256
 * prior event id.
257
 */
258
export const buildSupersedingBody = (
259
  prior: EngramBody,
260
  newValue: string | null,
261
  priorEventId: string,
262
  contentDigest?: string,
263
): EngramBody => {
264
  const { schema: _, ...companion } = prior.openagents;
265
  return buildEngramBody(
266
    prior.slug,
267
    newValue,
268
    {
269
      ...companion,
270
      contentDigest: contentDigest ?? engramContentDigest(newValue),
271
      supersedes: priorEventId,
272
      sourceEventRefs: [
273
        ...companion.sourceEventRefs,
274
        { eventId: priorEventId, role: "supersession" },
275
      ],
276
    },
277
  );
278
};
279
280
/**
281
 * Compute the NIP-01-style event id for an unsigned engram event: the SHA-256
282
 * digest of the canonicalized [0, pubkey, created_at, kind, tags, content]
283
 * tuple.
284
 */
285
export const computeEngramEventId = (
286
  event: Pick<EngramEvent, "pubkey" | "created_at" | "kind" | "tags" | "content">,
287
): string =>
288
  sha256Hex(
289
    canonicalStringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]),
290
  );
291
292
/**
293
 * Sign an engram event. The id is derived deterministically from the event
294
 * fields; the supplied signer produces the signature over that id.
295
 */
296
export const signEngramEvent = (
297
  event: Pick<EngramEvent, "pubkey" | "created_at" | "kind" | "tags" | "content">,
298
  sign: EngramSigner,
299
): EngramEvent => {
300
  const id = computeEngramEventId(event);
301
  const sig = sign(id);
302
  return S.decodeUnknownSync(EngramEvent)({ ...event, id, sig });
303
};
304
305
/**
306
 * Build and sign a fresh NIP-AE engram event. The `dTag` is the blinded
307
 * address; the `alt` tag is set to the canonical engram alt text.
308
 */
309
export const buildEngramEvent = (
310
  pubkey: string,
311
  created_at: number,
312
  dTag: string,
313
  content: string,
314
  sign: EngramSigner,
315
): EngramEvent =>
316
  signEngramEvent(
317
    {
318
      pubkey,
319
      created_at,
320
      kind: ENGRAM_KIND,
321
      tags: [
322
        ["d", dTag],
323
        ["alt", ENGRAM_ALT],
324
      ],
325
      content,
326
    },
327
    sign,
328
  );
329
330
/**
331
 * Build and sign a superseding engram. The new event reuses the prior `d` tag,
332
 * carries a greater `created_at`, and records `supersedes: prior.id` in its
333
 * companion body. The prior event is left untouched.
334
 */
335
export const signSupersedingEngram = (
336
  prior: EngramEvent,
337
  newValue: string | null,
338
  created_at: number,
339
  pubkey: string,
340
  sign: EngramSigner,
341
): EngramEvent => {
342
  if (created_at <= prior.created_at) {
343
    throw new Error("superseding engram must have a greater created_at");
344
  }
345
  const dTag = prior.tags.find((tag) => tag[0] === "d")?.[1];
346
  if (dTag === undefined) {
347
    throw new Error("prior engram is missing a d tag");
348
  }
349
  const priorBody = S.decodeUnknownSync(EngramBody)(JSON.parse(prior.content));
350
  const newContent = JSON.stringify(buildSupersedingBody(priorBody, newValue, prior.id));
351
  return buildEngramEvent(pubkey, created_at, dTag, newContent, sign);
352
};
353
354
/** Verify that an event id matches the canonical content digest of its fields. */
355
export const verifyEngramEventId = (event: EngramEvent): boolean =>
356
  event.id === computeEngramEventId(event);
357
358
/**
359
 * Verify a chain of superseding engrams. Each event id must be valid, each
360
 * `created_at` must be strictly increasing, and each event's companion body
361
 * must explicitly reference the previous event id in `supersedes`.
362
 */
363
export const verifySupersessionChain = (events: ReadonlyArray<EngramEvent>): boolean => {
364
  for (let i = 0; i < events.length; i += 1) {
365
    const event = events[i];
366
    if (!verifyEngramEventId(event)) {
367
      return false;
368
    }
369
    if (i === 0) {
370
      continue;
371
    }
372
    const prior = events[i - 1]!;
373
    if (event.created_at <= prior.created_at) {
374
      return false;
375
    }
376
    let body: EngramBody;
377
    try {
378
      body = S.decodeUnknownSync(EngramBody)(JSON.parse(event.content));
379
    } catch {
380
      return false;
381
    }
382
    if (body.openagents.supersedes !== prior.id) {
383
      return false;
384
    }
385
  }
386
  return true;
387
};
packages/agent-experience-memory/src/index.ts modified +3

@@ -36,3 +36,6 @@ export * from "./memory.js";

36 36
export * from "./measurement.js";
37 37
export * from "./owner-profile.js";
38 38
export * from "./graph-memory-store.js";
39
export * from "./engram.js";
40
export * from "./consolidation.js";
41
export * from "./subagent-memory.js";
packages/agent-experience-memory/src/subagent-memory.test.ts added +216

@@ -0,0 +1,216 @@

1
import { describe, expect, test } from "vite-plus/test";
2
3
import {
4
  buildSubagentMemoryContext,
5
  DEFAULT_SUBAGENT_MEMORY_BUDGET_TOKENS,
6
  HARVEST_LEDGER_SCHEMA_ID,
7
  harvestSubagentOutcome,
8
  ledgerEntriesAsHeuristics,
9
  SUBAGENT_MEMORY_HEADER,
10
} from "./subagent-memory.js";
11
12
const OWNER = "owner/test";
13
const PROJECT = "project/test";
14
15
describe("buildSubagentMemoryContext", () => {
16
  const heuristics = [
17
    { ref: "h1", text: "run the typecheck before pushing", confidence: 0.9 },
18
    { ref: "h2", text: "restart the gateway when the queue stalls", confidence: 0.7 },
19
    { ref: "h3", text: "prefer small pull requests over large ones", confidence: 0.4 },
20
  ];
21
22
  test("packs heuristics into a bounded advisory block", () => {
23
    const ctx = buildSubagentMemoryContext({ heuristics });
24
    expect(ctx.block.startsWith(SUBAGENT_MEMORY_HEADER)).toBe(true);
25
    expect(ctx.includedRefs.length).toBe(3);
26
    expect(ctx.usedTokens).toBeLessThanOrEqual(ctx.budgetTokens);
27
    for (const h of heuristics) {
28
      expect(ctx.block).toContain(h.text);
29
    }
30
  });
31
32
  test("drops lowest-priority items to fit the token budget", () => {
33
    const ctx = buildSubagentMemoryContext({
34
      heuristics,
35
      budgetTokens: estimateOf(heuristics.slice(0, 2)) + 1,
36
    });
37
    // The two high-confidence items fit; the low-confidence one does not.
38
    expect(ctx.includedRefs).toEqual(["h1", "h2"]);
39
    expect(ctx.droppedRefs).toContain("h3");
40
  });
41
42
  test("respects maxItems", () => {
43
    const ctx = buildSubagentMemoryContext({ heuristics, maxItems: 2 });
44
    expect(ctx.includedRefs.length).toBe(2);
45
  });
46
47
  test("alwaysInclude pins an item that would otherwise be dropped", () => {
48
    const ctx = buildSubagentMemoryContext({
49
      heuristics,
50
      budgetTokens: estimateOf([heuristics[0]!]) + 1,
51
      alwaysInclude: ["h3"],
52
    });
53
    expect(ctx.includedRefs).toContain("h3");
54
  });
55
56
  test("returns an empty block when there is nothing to inherit", () => {
57
    const empty = buildSubagentMemoryContext({ heuristics: [] });
58
    expect(empty.block).toBe("");
59
    expect(empty.includedRefs).toEqual([]);
60
  });
61
62
  test("ranks by cosine similarity to the task when embeddings are given", () => {
63
    const embedded = [
64
      { ref: "near", text: "gateway queue restart", embedding: [1, 0] },
65
      { ref: "far", text: "sourdough starter feeding", embedding: [-1, 0] },
66
    ];
67
    const ctx = buildSubagentMemoryContext({
68
      heuristics: embedded,
69
      taskEmbedding: [1, 0],
70
      budgetTokens: DEFAULT_SUBAGENT_MEMORY_BUDGET_TOKENS,
71
      maxItems: 1,
72
    });
73
    expect(ctx.includedRefs).toEqual(["near"]);
74
  });
75
76
  test("never leaks the parent's private per-case refs", () => {
77
    const ctx = buildSubagentMemoryContext({ heuristics });
78
    // Refs inside the block are opaque content digests, not parent ledger ids.
79
    expect(ctx.block).not.toContain(":h1");
80
    expect(ctx.block).not.toContain(":h2");
81
  });
82
83
  test("is deterministic", () => {
84
    const one = buildSubagentMemoryContext({ heuristics });
85
    const two = buildSubagentMemoryContext({ heuristics });
86
    expect(one).toEqual(two);
87
  });
88
});
89
90
/** The token estimate of the block these heuristics would produce. */
91
const estimateOf = (
92
  items: ReadonlyArray<{ readonly text: string }>,
93
): number =>
94
  Math.ceil(
95
    [SUBAGENT_MEMORY_HEADER, ...items.map((h) => `- ${h.text}`)].join("\n").length / 4,
96
  );
97
98
describe("harvestSubagentOutcome", () => {
99
  test("keeps clean findings as bounded ledger entries", () => {
100
    const result = harvestSubagentOutcome({
101
      ownerScope: OWNER,
102
      projectScope: PROJECT,
103
      outcome: {
104
        childId: "child-1",
105
        summary: "added the parser tests",
106
        findings: ["the parser rejects duplicate keys", "tests cover the error path"],
107
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
108
      },
109
    });
110
    expect(result.entries.length).toBe(2);
111
    expect(result.rejectedUnsafe).toBe(0);
112
    const entry = result.entries[0]!;
113
    expect(entry.schema).toBe(HARVEST_LEDGER_SCHEMA_ID);
114
    expect(entry.entryId).toMatch(/^harvest:[0-9a-f]{64}$/);
115
    expect(entry.ownerScope).toBe(OWNER);
116
    expect(entry.childId).toBe("child-1");
117
    expect(entry.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
118
    expect(entry.completedAt).toBe("2026-01-15T12:00:00.000Z");
119
  });
120
121
  test("rejects credential-shaped findings outright", () => {
122
    const token = "ghp_1234567890abcdef1234567890abcdef";
123
    const result = harvestSubagentOutcome({
124
      ownerScope: OWNER,
125
      projectScope: PROJECT,
126
      outcome: {
127
        childId: "child-1",
128
        summary: "done",
129
        findings: [`use ${token} for api calls`, "a clean finding"],
130
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
131
      },
132
    });
133
    expect(result.rejectedUnsafe).toBe(1);
134
    expect(result.entries.length).toBe(1);
135
    expect(JSON.stringify(result.entries)).not.toContain(token);
136
  });
137
138
  test("redacts soft PII but keeps the finding", () => {
139
    const result = harvestSubagentOutcome({
140
      ownerScope: OWNER,
141
      projectScope: PROJECT,
142
      outcome: {
143
        childId: "child-1",
144
        summary: "done",
145
        findings: ["email chris@example.com about the launch"],
146
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
147
      },
148
    });
149
    expect(result.entries.length).toBe(1);
150
    expect(result.entries[0]!.finding).not.toContain("chris@example.com");
151
    expect(result.entries[0]!.finding).toContain("[REDACTED:");
152
  });
153
154
  test("records inherited provenance refs on every entry", () => {
155
    const result = harvestSubagentOutcome({
156
      ownerScope: OWNER,
157
      projectScope: PROJECT,
158
      outcome: {
159
        childId: "child-2",
160
        summary: "done",
161
        findings: ["one finding"],
162
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
163
      },
164
      inheritedRefs: [{ patternRef: "pattern:abc" }],
165
    });
166
    expect(result.entries[0]!.inheritedRefs).toEqual(["pattern:abc"]);
167
  });
168
169
  test("falls back to the summary when no findings are supplied", () => {
170
    const result = harvestSubagentOutcome({
171
      ownerScope: OWNER,
172
      projectScope: PROJECT,
173
      outcome: {
174
        childId: "child-3",
175
        summary: "the whole summary becomes one entry",
176
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
177
      },
178
    });
179
    expect(result.entries.length).toBe(1);
180
    expect(result.entries[0]!.finding).toBe("the whole summary becomes one entry");
181
  });
182
183
  test("entry ids are deterministic per child and finding", () => {
184
    const run = (): string =>
185
      harvestSubagentOutcome({
186
        ownerScope: OWNER,
187
        projectScope: PROJECT,
188
        outcome: {
189
          childId: "child-4",
190
          summary: "s",
191
          findings: ["same finding"],
192
          completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
193
        },
194
      }).entries[0]!.entryId;
195
    expect(run()).toBe(run());
196
  });
197
198
  test("round-trips back into delegation as a heuristic", () => {
199
    const first = harvestSubagentOutcome({
200
      ownerScope: OWNER,
201
      projectScope: PROJECT,
202
      outcome: {
203
        childId: "child-5",
204
        summary: "s",
205
        findings: ["the flaky test needs a fresh database per run"],
206
        completedAtMs: Date.UTC(2026, 0, 15, 12, 0, 0),
207
      },
208
    });
209
    const heuristics = ledgerEntriesAsHeuristics(first.entries);
210
    expect(heuristics[0]!.text).toBe("the flaky test needs a fresh database per run");
211
    expect(heuristics[0]!.confidence).toBeGreaterThan(0);
212
213
    const ctx = buildSubagentMemoryContext({ heuristics });
214
    expect(ctx.block).toContain("fresh database per run");
215
  });
216
});
packages/agent-experience-memory/src/subagent-memory.ts added +258

@@ -0,0 +1,258 @@

1
import { Schema as S } from "effect";
2
3
import type { GlobalPattern } from "./contract/pattern.js";
4
import { factRef, OwnerScopeId, PatternRef, ProjectScopeId } from "./contract/refs.js";
5
import { canonicalStringify } from "./internal/canonical.js";
6
import { sha256Hex } from "./internal/sha256.js";
7
import { estimateTokens, packWithinBudget, topK } from "./ranking.js";
8
import { guardEngramContent } from "./engram.js";
9
10
/**
11
 * Scoped memory inheritance for subagent delegation (issue #226) and child
12
 * engram harvest with parent ledger re-integration (issue #227).
13
 *
14
 * Two pure helpers, no store, no host:
15
 *
16
 * - `buildSubagentMemoryContext` packages the parent's relevant heuristics and
17
 *   patterns into a bounded, redacted advisory block for one child prompt. The
18
 *   child inherits a read-only slice of the parent's distilled memory — never
19
 *   the parent's private per-case bank — so a delegated child can use what the
20
 *   parent knows without gaining access to what the parent owns.
21
 * - `harvestSubagentOutcome` turns a completed child output into parent-side
22
 *   engrams (a ledger of harvest records), re-guarding every value through the
23
 *   engram redaction boundary. A child's raw output never enters the parent
24
 *   ledger unredacted, and nothing harvested is trusted above its source.
25
 *
26
 * Both helpers are deterministic and provider-free: packing order, digests, and
27
 * ledger ids derive only from inputs.
28
 */
29
30
/** One distilled heuristic offered to a delegated child. */
31
export interface ParentHeuristic {
32
  readonly ref: string;
33
  readonly text: string;
34
  readonly confidence?: number;
35
  readonly embedding?: ReadonlyArray<number>;
36
}
37
38
export const SUBAGENT_MEMORY_HEADER = "[inherited parent memory — advisory only]" as const;
39
40
/** Defaults for the inherited-memory block. */
41
export const DEFAULT_SUBAGENT_MEMORY_BUDGET_TOKENS = 512 as const;
42
export const DEFAULT_SUBAGENT_MEMORY_MAX_ITEMS = 8 as const;
43
44
export type SubagentMemoryContext = Readonly<{
45
  /** The formatted advisory block; empty string when nothing qualified. */
46
  readonly block: string;
47
  readonly includedRefs: ReadonlyArray<string>;
48
  readonly droppedRefs: ReadonlyArray<string>;
49
  readonly usedTokens: number;
50
  readonly budgetTokens: number;
51
}>;
52
53
/**
54
 * Package the parent's heuristics into a bounded advisory block for one child
55
 * prompt.
56
 *
57
 * Selection: when a `taskEmbedding` and heuristic embeddings are supplied,
58
 * candidates are ranked by cosine similarity to the task (`topK`); otherwise
59
 * all candidates are eligible. Packing: pinned `alwaysInclude` refs fit first,
60
 * then confidence order, inside the token budget and item cap.
61
 *
62
 * The block carries no owner scope, no project scope, no raw episode text, and
63
 * no reference the child could resolve back into the parent's private bank:
64
 * refs are shortened to an opaque `h:<digest12>` form derived from content.
65
 */
66
export const buildSubagentMemoryContext = (
67
  input: Readonly<{
68
    heuristics: ReadonlyArray<ParentHeuristic>;
69
    taskText?: string;
70
    taskEmbedding?: ReadonlyArray<number>;
71
    budgetTokens?: number;
72
    maxItems?: number;
73
    alwaysInclude?: ReadonlyArray<string>;
74
  }>,
75
): SubagentMemoryContext => {
76
  const budgetTokens = input.budgetTokens ?? DEFAULT_SUBAGENT_MEMORY_BUDGET_TOKENS;
77
  const maxItems = input.maxItems ?? DEFAULT_SUBAGENT_MEMORY_MAX_ITEMS;
78
  const always = new Set(input.alwaysInclude ?? []);
79
80
  const opaqueRef = (text: string): string => {
81
    const digest = sha256Hex(canonicalStringify({ text }));
82
    return `h:${digest.slice(0, 12)}`;
83
  };
84
85
  let ranked: ReadonlyArray<ParentHeuristic> = input.heuristics;
86
  if (input.taskEmbedding !== undefined) {
87
    const withEmbedding = input.heuristics.filter((h) => h.embedding !== undefined);
88
    const k = Math.trunc(maxItems);
89
    if (k > 0 && withEmbedding.length > 0) {
90
      const top = topK(
91
        input.taskEmbedding,
92
        withEmbedding.map((h) => ({ ref: opaqueRef(h.text), embedding: h.embedding! })),
93
        Math.max(withEmbedding.length, k),
94
      );
95
      const rank = new Map(top.map((entry, index) => [entry.ref, index]));
96
      ranked = [...withEmbedding].sort(
97
        (left, right) => rank.get(opaqueRef(left.text))! - rank.get(opaqueRef(right.text))!,
98
      );
99
    }
100
  }
101
102
  const packItems = ranked.map((heuristic) => ({
103
    ref: opaqueRef(heuristic.text),
104
    priority: heuristic.confidence ?? 0.5,
105
    tokens: estimateTokens(`- ${opaqueRef(heuristic.text)}: ${heuristic.text}\n`),
106
    pinned: always.has(heuristic.ref),
107
  }));
108
  const packed = packWithinBudget(packItems, budgetTokens);
109
110
  // The packer enforces the token budget; the item cap is enforced here:
111
  // pinned entries first, then priority, ties keeping the ranked order
112
  // (Array.prototype.sort is stable), sliced to maxItems.
113
  const budgetSet = new Set(packed.included);
114
  const cappedSet = new Set(
115
    packItems
116
      .filter((item) => budgetSet.has(item.ref))
117
      .sort(
118
        (left, right) =>
119
          Number(right.pinned) - Number(left.pinned) || right.priority - left.priority,
120
      )
121
      .slice(0, Math.max(0, Math.trunc(maxItems)))
122
      .map((item) => item.ref),
123
  );
124
125
  // Keep input order for readability; membership is decided above.
126
  const included = input.heuristics.filter((h) => cappedSet.has(opaqueRef(h.text)));
127
  const dropped = input.heuristics
128
    .filter((h) => !cappedSet.has(opaqueRef(h.text)))
129
    .map((h) => h.ref);
130
131
  if (included.length === 0) {
132
    return {
133
      block: "",
134
      includedRefs: [],
135
      droppedRefs: [...new Set(dropped)],
136
      usedTokens: 0,
137
      budgetTokens,
138
    };
139
  }
140
141
  const lines = included.map((h) => `- ${opaqueRef(h.text)}: ${h.text}`);
142
  const block = [SUBAGENT_MEMORY_HEADER, ...lines].join("\n");
143
  return {
144
    block,
145
    includedRefs: included.map((h) => h.ref),
146
    droppedRefs: [...new Set(dropped)],
147
    usedTokens: estimateTokens(`${block}\n`),
148
    budgetTokens,
149
  };
150
};
151
152
/** A completed child outcome offered for harvest. */
153
export interface SubagentOutcome {
154
  readonly childId: string;
155
  readonly summary: string;
156
  /** Structured findings worth keeping as individual parent engrams. */
157
  readonly findings?: ReadonlyArray<string>;
158
  readonly completedAtMs: number;
159
}
160
161
/** The schema id written into every harvested ledger entry body. */
162
export const HARVEST_LEDGER_SCHEMA_ID = "openagents.subagent_harvest.v1" as const;
163
164
export const HarvestedLedgerEntry = S.Struct({
165
  schema: S.Literal("openagents.subagent_harvest.v1"),
166
  entryId: S.String.check(S.isPattern(/^harvest:[a-f0-9]{64}$/)),
167
  ownerScope: OwnerScopeId,
168
  projectScope: ProjectScopeId,
169
  childId: S.String.check(S.isMinLength(1), S.isMaxLength(256)),
170
  /** The redacted finding text kept in the parent ledger. */
171
  finding: S.String.check(S.isMinLength(1), S.isMaxLength(1000)),
172
  /** Provenance: the parent pattern or heuristic refs that seeded the child. */
173
  inheritedRefs: S.Array(PatternRef),
174
  completedAt: S.String.check(S.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/)),
175
  digest: S.String.check(S.isPattern(/^sha256:[a-f0-9]{64}$/)),
176
});
177
export type HarvestedLedgerEntry = typeof HarvestedLedgerEntry.Type;
178
179
const decodeEntry = S.decodeUnknownSync(HarvestedLedgerEntry);
180
const decodeInheritedRef = S.decodeUnknownSync(PatternRef);
181
182
/**
183
 * Harvest a completed child outcome into parent-ledger entries (issue #227).
184
 *
185
 * Every value passes the strict engram guard before it is kept: a finding
186
 * carrying credential-shaped material is rejected outright (counted in
187
 * `rejectedUnsafe`), soft PII is redacted but kept, and empty-after-redaction
188
 * findings are skipped. The summary itself is not stored verbatim; it seeds the
189
 * per-finding split so each ledger entry stays one bounded fact.
190
 */
191
export const harvestSubagentOutcome = (
192
  input: Readonly<{
193
    ownerScope: string;
194
    projectScope: string;
195
    outcome: SubagentOutcome;
196
    /** The refs of parent memories that were injected into this child. */
197
    inheritedRefs?: ReadonlyArray<GlobalPattern | { readonly patternRef: string }>;
198
  }>,
199
): Readonly<{
200
  entries: ReadonlyArray<HarvestedLedgerEntry>;
201
  rejectedUnsafe: number;
202
  skippedEmpty: number;
203
}> => {
204
  const entries: Array<HarvestedLedgerEntry> = [];
205
  let rejectedUnsafe = 0;
206
  let skippedEmpty = 0;
207
  const inheritedRefs = (input.inheritedRefs ?? []).map((ref) =>
208
    decodeInheritedRef(ref.patternRef),
209
  );
210
  const completedAt = new Date(input.outcome.completedAtMs).toISOString();
211
  for (const candidate of [
212
    ...input.outcome.findings ?? [],
213
    ...(input.outcome.findings === undefined ? [input.outcome.summary] : []),
214
  ]) {
215
    const verdict = guardEngramContent(candidate);
216
    if (!verdict.storable) {
217
      rejectedUnsafe += 1;
218
      continue;
219
    }
220
    const redacted = verdict.redacted?.trim() ?? "";
221
    if (redacted.length === 0) {
222
      skippedEmpty += 1;
223
      continue;
224
    }
225
    const digestValue = sha256Hex(canonicalStringify({ finding: redacted }));
226
    entries.push(
227
      decodeEntry({
228
        schema: HARVEST_LEDGER_SCHEMA_ID,
229
        entryId: `harvest:${sha256Hex(canonicalStringify({ childId: input.outcome.childId, finding: redacted }))}`,
230
        ownerScope: input.ownerScope,
231
        projectScope: input.projectScope,
232
        childId: input.outcome.childId,
233
        finding: redacted,
234
        inheritedRefs,
235
        completedAt,
236
        digest: `sha256:${digestValue}`,
237
      }),
238
    );
239
  }
240
  return { entries, rejectedUnsafe, skippedEmpty } as const;
241
};
242
243
/**
244
 * Re-integrate harvested entries into the parent's recall path: fold them into
245
 * the same shape as `ParentHeuristic` so a later delegation inherits what its
246
 * siblings learned. Confidence starts at the harvest floor — unproven until a
247
 * consolidation cycle lifts it.
248
 */
249
export const HARVEST_CONFIDENCE_FLOOR = 0.35 as const;
250
251
export const ledgerEntriesAsHeuristics = (
252
  entries: ReadonlyArray<HarvestedLedgerEntry>,
253
): ReadonlyArray<ParentHeuristic> =>
254
  entries.map((entry) => ({
255
    ref: factRef(entry.entryId),
256
    text: entry.finding,
257
    confidence: HARVEST_CONFIDENCE_FLOOR,
258
  }));
packages/openagents-cli/test/coder-thread.test.ts modified +4 -4

@@ -141,7 +141,7 @@ describe("openThread", () => {

141 141
    expect(source.threadId).toBe(THREAD_ID);
142 142
    expect(source.model).toBe("gpt-5.6-luna");
143 143
    expect(calls[0]?.method).toBe("POST");
144
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v3/threads`);
144
    expect(calls[0]?.url).toBe(`${ORIGIN}/api/v1/threads`);
145 145
    expect(calls[0]?.authorization).toBe(`Bearer ${ACCOUNT_TOKEN}`);
146 146
    expect(calls[0]?.body).toEqual({ objective: "coder in repo on main", reasoning: "high" });
147 147
  });

@@ -206,7 +206,7 @@ describe("openThread", () => {

206 206
  it("carries the server's typed refusal when the account holds its last thread", async () => {
207 207
    const sentence =
208 208
      "This account holds 8 open threads and the configured maximum is 8. " +
209
      "Revoke a thread with DELETE /api/v3/threads/{thread_id} before opening another.";
209
      "Revoke a thread with DELETE /api/v1/threads/{thread_id} before opening another.";
210 210
    stub({
211 211
      create: json(429, {
212 212
        message: sentence,

@@ -633,7 +633,7 @@ describe("ThreadReplySource", () => {

633 633
    expect(failure).toBeInstanceOf(ThreadUnavailable);
634 634
    expect(failure).toMatchObject({
635 635
      code: "grant_revoked",
636
      message: "This thread was revoked. Start a new session to open another.",
636
      message: "This thread is no longer live. Start a new session to open another.",
637 637
    });
638 638
  });
639 639

@@ -653,7 +653,7 @@ describe("ThreadReplySource", () => {

653 653
    await (await open()).revoke();
654 654
655 655
    const removal = calls.find((call) => call.method === "DELETE");
656
    expect(removal?.url).toBe(`${ORIGIN}/api/v3/threads/${THREAD_ID}`);
656
    expect(removal?.url).toBe(`${ORIGIN}/api/v1/threads/${THREAD_ID}`);
657 657
    expect(removal?.authorization).toBe(`Bearer ${ACCOUNT_TOKEN}`);
658 658
  });
659 659
scripts/uncalled-production-symbol-baseline.json modified +89 -61

@@ -1773,6 +1773,22 @@

1773 1773
    "scripts/github-issue-triage.ts#issueHasAnyLabel"
1774 1774
  ],
1775 1775
  "allowed": [
1776
    {
1777
      "ref": "packages/agent-experience-memory/src/consolidation.ts#consolidateEpisodes",
1778
      "reason": "Background consolidation loop for agent memory, issue openagents.com#224: clusters episodes and synthesises lessons. Landed with its suites ahead of the loop that runs it, which is what #224 builds."
1779
    },
1780
    {
1781
      "ref": "packages/agent-experience-memory/src/consolidation.ts#promoteHeuristicToPattern",
1782
      "reason": "The promotion half of the same consolidation loop, issue openagents.com#224. Called once #224 has a scheduler to call it from."
1783
    },
1784
    {
1785
      "ref": "packages/agent-experience-memory/src/engram.ts#signSupersedingEngram",
1786
      "reason": "Supersession signing from the formal engram schema, issue openagents.com#221. The schema and its redaction invariants land before the relay sync adapter in #222 writes through them."
1787
    },
1788
    {
1789
      "ref": "packages/agent-experience-memory/src/engram.ts#verifySupersessionChain",
1790
      "reason": "The verification half of the same schema, issue openagents.com#221. Read by the deterministic projection worker in #223, which is not built yet."
1791
    },
1776 1792
    {
1777 1793
      "ref": "packages/agent-experience-memory/src/graph-memory-store.ts#GraphMemoryStoreInterface.applyDeletePlan",
1778 1794
      "reason": "Owner deletion half of the published graph-memory SDK contract: the typed, receipted delete plan every store layer implements. Its caller was the deleted Electron owner-lifecycle surface; hosted Sarah graph memory (openagents#9189) is the surviving composition root that owes owners a deletion path."

@@ -1781,6 +1797,18 @@

1781 1797
      "ref": "packages/agent-experience-memory/src/graph-memory-store.ts#GraphMemoryStoreInterface.exportArchive",
1782 1798
      "reason": "Owner data-portability half of the published graph-memory SDK contract, implemented by every store layer and paired with the live importArchive. Its caller was the deleted Electron owner-lifecycle surface; hosted Sarah graph memory (openagents#9189) is the surviving composition root that owes owners an export path."
1783 1799
    },
1800
    {
1801
      "ref": "packages/agent-experience-memory/src/subagent-memory.ts#buildSubagentMemoryContext",
1802
      "reason": "Scoped memory inheritance for delegated children, issue openagents.com#226: the bootstrap heuristics a child is given. Its caller is the coder's delegate path, which #226 changes."
1803
    },
1804
    {
1805
      "ref": "packages/agent-experience-memory/src/subagent-memory.ts#harvestSubagentOutcome",
1806
      "reason": "Child engram harvest, issue openagents.com#227: what a finished child contributes back to the parent ledger. Called by the delegate path #227 builds."
1807
    },
1808
    {
1809
      "ref": "packages/agent-experience-memory/src/subagent-memory.ts#ledgerEntriesAsHeuristics",
1810
      "reason": "The parent-ledger reintegration half of the same harvest, issue openagents.com#227."
1811
    },
1784 1812
    {
1785 1813
      "ref": "packages/agent-surface/src/index.ts#projectSafeMessageChain",
1786 1814
      "reason": "AFS-12 compose half of the cross-surface safe projection. The package documents web and mobile as READ/COMPOSE hosts that compose the same bounded message chains; both depend on @openagentsinc/agent-surface today, and the deleted Electron providers were the first callers. The read half safeMessageChainOf stays live."

@@ -1801,6 +1829,10 @@

1801 1829
      "ref": "packages/all-work-contract/src/client.generated.ts#AllWorkClient.workIndexSubscribe",
1802 1830
      "reason": "Published generated SDK method; Omega and other All Work clients will call it after they adopt the v0.2 client package."
1803 1831
    },
1832
    {
1833
      "ref": "packages/all-work-contract/src/client.generated.ts#AllWorkClient.workSnapshotRead",
1834
      "reason": "Published generated SDK method; Omega and other All Work clients will call it after they adopt the v0.2 client package."
1835
    },
1804 1836
    {
1805 1837
      "ref": "packages/all-work-contract/src/client.generated.ts#AllWorkClient.workroomActivityCommit",
1806 1838
      "reason": "Published generated SDK method; TypeScript All Work clients will call it when they adopt the v0.2 signed Workroom client used by Omegas installed Rust consumer."

@@ -1817,10 +1849,6 @@

1817 1849
      "ref": "packages/all-work-contract/src/client.generated.ts#AllWorkClient.workroomActivityPublish",
1818 1850
      "reason": "Published generated SDK method; TypeScript All Work clients will call it when they adopt the v0.2 signed Workroom publication flow used by Omegas installed Rust consumer."
1819 1851
    },
1820
    {
1821
      "ref": "packages/all-work-contract/src/client.generated.ts#AllWorkClient.workSnapshotRead",
1822
      "reason": "Published generated SDK method; Omega and other All Work clients will call it after they adopt the v0.2 client package."
1823
    },
1824 1852
    {
1825 1853
      "ref": "packages/all-work-contract/src/client.generated.ts#makeAllWorkClient",
1826 1854
      "reason": "Published generated SDK constructor; Omega and other All Work clients will call it after they adopt the v0.2 client package."

@@ -1853,14 +1881,14 @@

1853 1881
      "ref": "packages/all-work-contract/src/organization-membership-authority.ts#provisionFileOrganizationMembershipState",
1854 1882
      "reason": "Intentional operator provisioning API; the installed Omega bootstrap will call it when non-human organization grant provisioning moves out of its current conformance fixture."
1855 1883
    },
1856
    {
1857
      "ref": "packages/all-work-contract/src/planning-authority.ts#inMemoryPlanningStateStoreLayer",
1858
      "reason": "Intentional in-memory adapter for downstream All Work SDK consumers; package and integration conformance suites call it instead of production persistence."
1859
    },
1860 1884
    {
1861 1885
      "ref": "packages/all-work-contract/src/planning-authority.ts#PlanningAuthorityLive",
1862 1886
      "reason": "Published composable Effect layer; downstream All Work hosts will provide it when they adopt the planning authority outside the repository reference process."
1863 1887
    },
1888
    {
1889
      "ref": "packages/all-work-contract/src/planning-authority.ts#inMemoryPlanningStateStoreLayer",
1890
      "reason": "Intentional in-memory adapter for downstream All Work SDK consumers; package and integration conformance suites call it instead of production persistence."
1891
    },
1864 1892
    {
1865 1893
      "ref": "packages/all-work-contract/src/repository-claim-authority.ts#inMemoryRepositoryClaimStateStoreLayer",
1866 1894
      "reason": "Intentional in-memory adapter for downstream All Work SDK consumers; package and integration conformance suites call it instead of production persistence."

@@ -1889,6 +1917,18 @@

1889 1917
      "ref": "packages/apple-fm-runtime/src/supervisor.ts#AppleFmSupervisor.ensureStarted",
1890 1918
      "reason": "Idempotent start seam of the neutral Apple FM runtime, which apps/pylon/packages/runtime re-exports wholesale (export * as AppleFmNeutralRuntime). The deleted Electron apple-fm host was the in-repo caller; the sibling members status/refresh/runTurn/stop stay and would be unusable without a start."
1891 1919
    },
1920
    {
1921
      "ref": "packages/assurance-spec/src/admission.ts#assuranceReviewSetDigest",
1922
      "reason": "Digests the review set an admission decision is bound to, so a later run can prove the reviewed bytes did not change. Its only production caller was the deleted run-mvp-assurance.ts (#9325). It is needed again by the replacement assurance runner that admits the surviving Omega Full Auto spec."
1923
    },
1924
    {
1925
      "ref": "packages/assurance-spec/src/full-gate.ts#parseVitePlusTestSummary",
1926
      "reason": "Parses a vite-plus test summary into the full-gate pass/fail counts an assurance receipt records. Its only production caller was the deleted run-mvp-assurance.ts (#9325). The replacement assurance runner for the surviving admitted specs is the successor caller."
1927
    },
1928
    {
1929
      "ref": "packages/assurance-spec/src/manifest.ts#compileAssuranceManifest",
1930
      "reason": "Compiles an AssuranceSpec into its obligation manifest. Its only in-repo production caller was packages/assurance-spec/scripts/run-mvp-assurance.ts, which was hard-wired to pnpm --dir apps/openagents-desktop run verify and was deleted with the Electron app (#9325). The kit still compiles manifests for the surviving admitted specs; the successor caller is the replacement assurance runner for specs/omega/full-auto.assurance-spec.md."
1931
    },
1892 1932
    {
1893 1933
      "ref": "packages/behavior-contracts/src/market-swap-compare.ts#marketSwapCompareContractRegistry",
1894 1934
      "reason": "Behaviour-contract registry for multi-provider quote comparison (openagents#9318), validated and coverage-checked by its package test; the registry report tooling and the SWAP-0 (openagents#9315) swap surface consume it when the widget shell lands."

@@ -1941,6 +1981,10 @@

1941 1981
      "ref": "packages/forensic-contract/src/claims.ts#evaluateClaimRevisionOrigin",
1942 1982
      "reason": "Published forensic contract API; OFR-004 and OFR-008 will call it from append-only claim revision and Omega review when those issues land."
1943 1983
    },
1984
    {
1985
      "ref": "packages/forensic-contract/src/coldcard-generator.ts#LIBNGU_INITIAL_YASMARANG_STATE",
1986
      "reason": "Published pinned libngu initial state; the native forensic worker vector builder will use it for admitted vulnerable and mutation reproductions."
1987
    },
1944 1988
    {
1945 1989
      "ref": "packages/forensic-contract/src/coldcard-generator.ts#admitColdcardGeneratorEvidence",
1946 1990
      "reason": "Published generator-evidence admission gate for OFR-015; the forensic evaluator route will call it once golden vectors are sourced independently of our own reproduction and a throughput measurement exists from an admitted OpenAgents Cloud worker. It currently refuses the checked-in corpus, which is the honest state."

@@ -1957,10 +2001,6 @@

1957 2001
      "ref": "packages/forensic-contract/src/coldcard-generator.ts#evaluateColdcardGeneratorVector",
1958 2002
      "reason": "Published frozen-vector evaluator; the native forensic worker route will call it for vulnerable, fixed, and mutation suites on admitted OpenAgents Cloud workers."
1959 2003
    },
1960
    {
1961
      "ref": "packages/forensic-contract/src/coldcard-generator.ts#LIBNGU_INITIAL_YASMARANG_STATE",
1962
      "reason": "Published pinned libngu initial state; the native forensic worker vector builder will use it for admitted vulnerable and mutation reproductions."
1963
    },
1964 2004
    {
1965 2005
      "ref": "packages/forensic-contract/src/coldcard-generator.ts#reproduceColdcardOwnedFixture",
1966 2006
      "reason": "Published secret-safe reproduction boundary; the forensic evaluator route will call it only for synthetic or explicitly owner-authorized fixtures after the OpenAgents Cloud execution adapter lands."

@@ -2019,23 +2059,23 @@

2019 2059
    },
2020 2060
    {
2021 2061
      "ref": "packages/mkt-swp-compare/src/reservation.ts#detectReservationForks",
2022
      "reason": "Reservation fork detection (openagents#9318 §3); SWAP-0 (openagents#9315) runs it over ingested quotes and feeds the forks into compareTableView when the widget shell lands."
2062
      "reason": "Reservation fork detection (openagents#9318 \u00a73); SWAP-0 (openagents#9315) runs it over ingested quotes and feeds the forks into compareTableView when the widget shell lands."
2023 2063
    },
2024 2064
    {
2025 2065
      "ref": "packages/mkt-swp-compare/src/reservation.ts#observeReservations",
2026
      "reason": "Reservation observation projection (openagents#9318 §3); SWAP-0 (openagents#9315) calls it on quote ingestion before fork detection when the widget shell lands."
2066
      "reason": "Reservation observation projection (openagents#9318 \u00a73); SWAP-0 (openagents#9315) calls it on quote ingestion before fork detection when the widget shell lands."
2027 2067
    },
2028 2068
    {
2029 2069
      "ref": "packages/mkt-swp-compare/src/selection.ts#orderAcceptance",
2030
      "reason": "Indicative-quote acceptance discipline (openagents#9318 §2); SWAP-0 (openagents#9315) drives it from provider Status ingestion after order creation."
2070
      "reason": "Indicative-quote acceptance discipline (openagents#9318 \u00a72); SWAP-0 (openagents#9315) drives it from provider Status ingestion after order creation."
2031 2071
    },
2032 2072
    {
2033 2073
      "ref": "packages/mkt-swp-compare/src/selection.ts#selectOrder",
2034
      "reason": "Order selection discipline (openagents#9318 §7); SWAP-0 (openagents#9315) calls it when the user selects a quote in the comparison table."
2074
      "reason": "Order selection discipline (openagents#9318 \u00a77); SWAP-0 (openagents#9315) calls it when the user selects a quote in the comparison table."
2035 2075
    },
2036 2076
    {
2037 2077
      "ref": "packages/mkt-swp-compare/src/testkit.ts#custodialMintCustody",
2038
      "reason": "Seeded custodial mint-route fixture (openagents#9318 §5) for the comparison corpus until immortal#14 lands a second live provider; SWAP-0 demo seeding consumes it."
2078
      "reason": "Seeded custodial mint-route fixture (openagents#9318 \u00a75) for the comparison corpus until immortal#14 lands a second live provider; SWAP-0 demo seeding consumes it."
2039 2079
    },
2040 2080
    {
2041 2081
      "ref": "packages/mkt-swp-compare/src/testkit.ts#testFirmHardQuote",

@@ -2043,7 +2083,7 @@

2043 2083
    },
2044 2084
    {
2045 2085
      "ref": "packages/mkt-swp-compare/src/testkit.ts#testVerifyReport",
2046
      "reason": "Seeded engine verify-report fixture (openagents#9318 §6); SWAP-0 demo seeding consumes it until the engine binding lands behind the SWAP-0 boundary."
2086
      "reason": "Seeded engine verify-report fixture (openagents#9318 \u00a76); SWAP-0 demo seeding consumes it until the engine binding lands behind the SWAP-0 boundary."
2047 2087
    },
2048 2088
    {
2049 2089
      "ref": "packages/mkt-swp-compare/src/view.ts#compareTableView",

@@ -2059,11 +2099,11 @@

2059 2099
    },
2060 2100
    {
2061 2101
      "ref": "packages/mkt-swp-destination/src/qr.ts#acceptScannedText",
2062
      "reason": "QR scan intake routing through the shared destination parser (openagents#9317 §8); SWAP-0 (openagents#9315) mounts the scanner in the swap widget shell and becomes its production caller."
2102
      "reason": "QR scan intake routing through the shared destination parser (openagents#9317 \u00a78); SWAP-0 (openagents#9315) mounts the scanner in the swap widget shell and becomes its production caller."
2063 2103
    },
2064 2104
    {
2065 2105
      "ref": "packages/mkt-swp-destination/src/qr.ts#qrUnavailable",
2066
      "reason": "Default no-camera capability for hosts without a scanner (openagents#9317 §8); SWAP-0 (openagents#9315) provides it where the widget shell has no camera probe."
2106
      "reason": "Default no-camera capability for hosts without a scanner (openagents#9317 \u00a78); SWAP-0 (openagents#9315) provides it where the widget shell has no camera probe."
2067 2107
    },
2068 2108
    {
2069 2109
      "ref": "packages/mkt-swp-destination/src/resolve.ts#resolveDeferredDestination",

@@ -2119,28 +2159,24 @@

2119 2159
    },
2120 2160
    {
2121 2161
      "ref": "packages/mkt-swp-pair/src/view.ts#feePanelView",
2122
      "reason": "Render-ready rate/fee panel view model for openagents#9316 §5; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2162
      "reason": "Render-ready rate/fee panel view model for openagents#9316 \u00a75; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2123 2163
    },
2124 2164
    {
2125 2165
      "ref": "packages/mkt-swp-pair/src/view.ts#limitsView",
2126
      "reason": "Render-ready pre-typed limits view for openagents#9316 §4; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2166
      "reason": "Render-ready pre-typed limits view for openagents#9316 \u00a74; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2127 2167
    },
2128 2168
    {
2129 2169
      "ref": "packages/mkt-swp-pair/src/view.ts#pairSelectorView",
2130
      "reason": "Render-ready pair selector view with pre-selection reachability for openagents#9316 §1; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2170
      "reason": "Render-ready pair selector view with pre-selection reachability for openagents#9316 \u00a71; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2131 2171
    },
2132 2172
    {
2133 2173
      "ref": "packages/mkt-swp-pair/src/view.ts#primaryActionView",
2134
      "reason": "Render-ready primary-action gate view (single most proximate refusal) for openagents#9316 §4; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2174
      "reason": "Render-ready primary-action gate view (single most proximate refusal) for openagents#9316 \u00a74; SWAP-0 (openagents#9315) mounts the pair component in the swap widget shell and renders this view."
2135 2175
    },
2136 2176
    {
2137 2177
      "ref": "packages/mkt-swp-session-store/src/export.ts#EXPORT_SENSITIVITY_KEY",
2138 2178
      "reason": "Typed sensitivity-notice key surfaces must present alongside the history export download (openagents#9320 audit follow-up); SWAP-0/SWAP-7 (openagents#9315/#9322) mount the History surface and render it next to the export action."
2139 2179
    },
2140
    {
2141
      "ref": "packages/mkt-swp-session-store/src/export.ts#importRefusalKeyOf",
2142
      "reason": "Producer binding every HistoryImportError.reason to its swap-i18n catalog key (openagents#9320 audit follow-up: the refusal keys previously had no producer); SWAP-0/SWAP-7 (openagents#9315/#9322) render import refusals through it."
2143
    },
2144 2180
    {
2145 2181
      "ref": "packages/mkt-swp-session-store/src/export.ts#exportPrivateHistory",
2146 2182
      "reason": "History export for openagents#9320; SWAP-0/SWAP-7 (openagents#9315/#9322) mount the History surface in the swap shell and wire the export download as its production caller."

@@ -2150,17 +2186,21 @@

2150 2186
      "reason": "History import for openagents#9320 (the escape hatch Boltz never shipped); SWAP-0/SWAP-7 (openagents#9315/#9322) mount the History surface and wire the import file picker as its production caller."
2151 2187
    },
2152 2188
    {
2153
      "ref": "packages/mkt-swp-session-store/src/history.ts#HISTORY_EMPTY_KEY",
2154
      "reason": "Empty-state message key for the History surface (openagents#9320); SWAP-0 (openagents#9315) renders the History list in the swap shell and becomes its production caller."
2189
      "ref": "packages/mkt-swp-session-store/src/export.ts#importRefusalKeyOf",
2190
      "reason": "Producer binding every HistoryImportError.reason to its swap-i18n catalog key (openagents#9320 audit follow-up: the refusal keys previously had no producer); SWAP-0/SWAP-7 (openagents#9315/#9322) render import refusals through it."
2155 2191
    },
2156 2192
    {
2157
      "ref": "packages/mkt-swp-session-store/src/history.ts#historyRows",
2158
      "reason": "Actionability-first History view model for openagents#9320; SWAP-0 (openagents#9315) renders the History list in the swap shell and becomes its production caller."
2193
      "ref": "packages/mkt-swp-session-store/src/history.ts#HISTORY_EMPTY_KEY",
2194
      "reason": "Empty-state message key for the History surface (openagents#9320); SWAP-0 (openagents#9315) renders the History list in the swap shell and becomes its production caller."
2159 2195
    },
2160 2196
    {
2161 2197
      "ref": "packages/mkt-swp-session-store/src/history.ts#RELOAD_GUARD_KEY",
2162 2198
      "reason": "Reload-guard prompt key for openagents#9320; SWAP-0 (openagents#9315) wires the navigation guard in the swap shell and becomes its production caller."
2163 2199
    },
2200
    {
2201
      "ref": "packages/mkt-swp-session-store/src/history.ts#historyRows",
2202
      "reason": "Actionability-first History view model for openagents#9320; SWAP-0 (openagents#9315) renders the History list in the swap shell and becomes its production caller."
2203
    },
2164 2204
    {
2165 2205
      "ref": "packages/mkt-swp-session-store/src/resume.ts#planResume",
2166 2206
      "reason": "App-wide resume planner for openagents#9320; SWAP-0 (openagents#9315) runs it at swap-shell boot to re-attach every outstanding session's subscription."

@@ -2169,10 +2209,6 @@

2169 2209
      "ref": "packages/mkt-swp-session-store/src/resume.ts#reloadGuard",
2170 2210
      "reason": "Irreversible-effect navigation-guard verdict for openagents#9320; SWAP-0 (openagents#9315) wires it to beforeunload in the swap shell."
2171 2211
    },
2172
    {
2173
      "ref": "packages/mkt-swp-session-store/src/store.ts#openSessionStore",
2174
      "reason": "Session-store constructor for openagents#9320; SWAP-0 (openagents#9315) opens the store at swap-shell boot over the browser storage binding and becomes its production caller."
2175
    },
2176 2212
    {
2177 2213
      "ref": "packages/mkt-swp-session-store/src/store.ts#SessionStore.appendSignedRecord",
2178 2214
      "reason": "Signed-record ingestion for openagents#9320; the SWAP-0 (openagents#9315) engine boundary appends accepted records from the live subscription fold."

@@ -2193,6 +2229,10 @@

2193 2229
      "ref": "packages/mkt-swp-session-store/src/store.ts#SessionStore.recordEffectResult",
2194 2230
      "reason": "Durable effect-result recording for openagents#9320; the SWAP-0 (openagents#9315) engine boundary records wallet/broadcast results to close the idempotency window."
2195 2231
    },
2232
    {
2233
      "ref": "packages/mkt-swp-session-store/src/store.ts#openSessionStore",
2234
      "reason": "Session-store constructor for openagents#9320; SWAP-0 (openagents#9315) opens the store at swap-shell boot over the browser storage binding and becomes its production caller."
2235
    },
2196 2236
    {
2197 2237
      "ref": "packages/mkt-swp-session-store/src/testkit.ts#CrashingKv.survivingKv",
2198 2238
      "reason": "Crash-simulation testkit surface exported under ./testkit for this package's crash-mid-write suites and the SWAP-0/SWAP-4 (openagents#9315/#9319) drill suites."

@@ -2293,14 +2333,14 @@

2293 2333
      "ref": "packages/nip-mkt/src/generated.ts#GATEWAY_LIMITS",
2294 2334
      "reason": "Published generated relay configuration catalog; openagents.com issue #9310 will use the pinned recipient-rate environment name in local market diagnostics."
2295 2335
    },
2296
    {
2297
      "ref": "packages/nip-mkt/src/generated.ts#MKT_KIND_DEFINITIONS",
2298
      "reason": "Published generated kind metadata for downstream NIP-MKT clients; openagents.com issue #9310 will use publication and enforcement scope when presenting discovery and negotiation records."
2299
    },
2300 2336
    {
2301 2337
      "ref": "packages/nip-mkt/src/generated.ts#MKT_KINDS",
2302 2338
      "reason": "Published generated kind catalog for downstream NIP-MKT clients; openagents.com issue #9310 will use it when building discovery and negotiation filters."
2303 2339
    },
2340
    {
2341
      "ref": "packages/nip-mkt/src/generated.ts#MKT_KIND_DEFINITIONS",
2342
      "reason": "Published generated kind metadata for downstream NIP-MKT clients; openagents.com issue #9310 will use publication and enforcement scope when presenting discovery and negotiation records."
2343
    },
2304 2344
    {
2305 2345
      "ref": "packages/nip-mkt/src/generated.ts#OPAQUE_TRANSPORT",
2306 2346
      "reason": "Published NIP-MKT transport policy for SDK consumers; openagents.com issue #9310 will use it to select wrapped private delivery and recipient-scoped reads."

@@ -2363,15 +2403,15 @@

2363 2403
    },
2364 2404
    {
2365 2405
      "ref": "packages/omega-effectd/scripts/full-auto-control-client.ts#buildFullAutoPolicyOptions",
2366
      "reason": "Published Full Auto control client shipped under the package's ./scripts/* export. Omega (github.com/OpenAgentsInc/omega, pinned by pack:digest, never a workspace path) is the surviving caller; docs/omega/2026-07-24-full-auto-port-audit.md §8.1 keeps the control server and its client until the framed protocol replaces them."
2406
      "reason": "Published Full Auto control client shipped under the package's ./scripts/* export. Omega (github.com/OpenAgentsInc/omega, pinned by pack:digest, never a workspace path) is the surviving caller; docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1 keeps the control server and its client until the framed protocol replaces them."
2367 2407
    },
2368 2408
    {
2369 2409
      "ref": "packages/omega-effectd/scripts/full-auto-control-client.ts#readControlConnection",
2370
      "reason": "Published Full Auto control client connection reader (./scripts/* export). The deleted Electron full-auto CLI was its in-repo caller; Omega's packaged omega-effectd is the surviving caller per docs/omega/2026-07-24-full-auto-port-audit.md §8.1."
2410
      "reason": "Published Full Auto control client connection reader (./scripts/* export). The deleted Electron full-auto CLI was its in-repo caller; Omega's packaged omega-effectd is the surviving caller per docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1."
2371 2411
    },
2372 2412
    {
2373 2413
      "ref": "packages/omega-effectd/src/engine/full-auto-churn.ts#buildFullAutoTurnAction",
2374
      "reason": "Full Auto turn-action classifier in the ./engine/* published surface. Its Schema is already consumed by full-auto-run-registry; the constructor's caller was the deleted Electron main process, and Omega GPUI becomes the fourth caller of the ported engine (docs/omega/2026-07-24-full-auto-port-audit.md §7.2)."
2414
      "reason": "Full Auto turn-action classifier in the ./engine/* published surface. Its Schema is already consumed by full-auto-run-registry; the constructor's caller was the deleted Electron main process, and Omega GPUI becomes the fourth caller of the ported engine (docs/omega/2026-07-24-full-auto-port-audit.md \u00a77.2)."
2375 2415
    },
2376 2416
    {
2377 2417
      "ref": "packages/omega-effectd/src/engine/full-auto-churn.ts#detectFullAutoChurn",

@@ -2383,23 +2423,23 @@

2383 2423
    },
2384 2424
    {
2385 2425
      "ref": "packages/omega-effectd/src/engine/full-auto-control-server.ts#isFullAutoControlEnabled",
2386
      "reason": "Env gate for the Full Auto loopback control API. docs/omega/2026-07-24-full-auto-port-audit.md §8.1 explicitly keeps the control server and contract in omega-effectd until the framed protocol replaces them; the deleted Electron main process was the in-repo starter."
2426
      "reason": "Env gate for the Full Auto loopback control API. docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1 explicitly keeps the control server and contract in omega-effectd until the framed protocol replaces them; the deleted Electron main process was the in-repo starter."
2387 2427
    },
2388 2428
    {
2389 2429
      "ref": "packages/omega-effectd/src/engine/full-auto-control-server.ts#startFullAutoControlServer",
2390
      "reason": "The OpenAPI-documented Full Auto loopback control server. docs/omega/2026-07-24-full-auto-port-audit.md §8.1 keeps it in omega-effectd until the framed protocol supersedes it; its FullAutoControlCapabilities type is already consumed by the live protocol/server.ts and full-auto-run-actions.ts."
2430
      "reason": "The OpenAPI-documented Full Auto loopback control server. docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1 keeps it in omega-effectd until the framed protocol supersedes it; its FullAutoControlCapabilities type is already consumed by the live protocol/server.ts and full-auto-run-actions.ts."
2391 2431
    },
2392 2432
    {
2393 2433
      "ref": "packages/omega-effectd/src/engine/full-auto-lane.ts#fullAutoPrompt",
2394
      "reason": "Lane prompt builder in the published ./engine/* surface. The deleted Electron codex runtime was its in-repo caller; Omega dispatches admitted provider turns through the same ported lane (docs/omega/2026-07-24-full-auto-port-audit.md §7.1)."
2434
      "reason": "Lane prompt builder in the published ./engine/* surface. The deleted Electron codex runtime was its in-repo caller; Omega dispatches admitted provider turns through the same ported lane (docs/omega/2026-07-24-full-auto-port-audit.md \u00a77.1)."
2395 2435
    },
2396 2436
    {
2397 2437
      "ref": "packages/omega-effectd/src/engine/full-auto-mission.ts#appendFullAutoQueuedInstruction",
2398
      "reason": "Mission queued-instruction appender; docs/omega/2026-07-24-full-auto-port-audit.md §8.1 keeps mission/verification/completion in omega-effectd. The module's compile/render functions are already live in protocol/server.ts; only the queued-instruction path lost its Electron caller."
2438
      "reason": "Mission queued-instruction appender; docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1 keeps mission/verification/completion in omega-effectd. The module's compile/render functions are already live in protocol/server.ts; only the queued-instruction path lost its Electron caller."
2399 2439
    },
2400 2440
    {
2401 2441
      "ref": "packages/omega-effectd/src/engine/full-auto-plan.ts#advanceFullAutoPlanFromTurn",
2402
      "reason": "Full Auto plan advancement from a turn transcript, in the published ./engine/* surface. The deleted Electron main process was the in-repo caller; Omega GPUI becomes the fourth caller of the same ported action surface (docs/omega/2026-07-24-full-auto-port-audit.md §7.2)."
2442
      "reason": "Full Auto plan advancement from a turn transcript, in the published ./engine/* surface. The deleted Electron main process was the in-repo caller; Omega GPUI becomes the fourth caller of the same ported action surface (docs/omega/2026-07-24-full-auto-port-audit.md \u00a77.2)."
2403 2443
    },
2404 2444
    {
2405 2445
      "ref": "packages/omega-effectd/src/engine/full-auto-plan.ts#makeFullAutoPlan",

@@ -2411,7 +2451,7 @@

2411 2451
    },
2412 2452
    {
2413 2453
      "ref": "packages/omega-effectd/src/engine/full-auto-routing.ts#makeFullAutoRoutingLaneGate",
2414
      "reason": "Fail-closed routing lane gate. docs/omega/2026-07-24-full-auto-port-audit.md §8.1 keeps full-auto-routing and readiness in omega-effectd; the module's policy validator and lane-gate type are already consumed by the live control server, readiness, and capacity modules."
2454
      "reason": "Fail-closed routing lane gate. docs/omega/2026-07-24-full-auto-port-audit.md \u00a78.1 keeps full-auto-routing and readiness in omega-effectd; the module's policy validator and lane-gate type are already consumed by the live control server, readiness, and capacity modules."
2415 2455
    },
2416 2456
    {
2417 2457
      "ref": "packages/portable-session-contract/src/ide13-model.ts#checkIdePortableModel",

@@ -2432,18 +2472,6 @@

2432 2472
    {
2433 2473
      "ref": "packages/sovereign-identity/src/machinery/retire.ts#ReadOnlyLegacyPlaintextGuardInterface.deleteFile",
2434 2474
      "reason": "Fail-closed half of the read-only legacy-plaintext guard: it exists to REFUSE deleting a protected legacy file while verification is active. Its sibling writeFile is unflagged only because .writeFile collides with common production dot-accesses; dropping the delete refusal would leave the guard able to block writes but not deletions."
2435
    },
2436
    {
2437
      "ref": "packages/assurance-spec/src/manifest.ts#compileAssuranceManifest",
2438
      "reason": "Compiles an AssuranceSpec into its obligation manifest. Its only in-repo production caller was packages/assurance-spec/scripts/run-mvp-assurance.ts, which was hard-wired to pnpm --dir apps/openagents-desktop run verify and was deleted with the Electron app (#9325). The kit still compiles manifests for the surviving admitted specs; the successor caller is the replacement assurance runner for specs/omega/full-auto.assurance-spec.md."
2439
    },
2440
    {
2441
      "ref": "packages/assurance-spec/src/admission.ts#assuranceReviewSetDigest",
2442
      "reason": "Digests the review set an admission decision is bound to, so a later run can prove the reviewed bytes did not change. Its only production caller was the deleted run-mvp-assurance.ts (#9325). It is needed again by the replacement assurance runner that admits the surviving Omega Full Auto spec."
2443
    },
2444
    {
2445
      "ref": "packages/assurance-spec/src/full-gate.ts#parseVitePlusTestSummary",
2446
      "reason": "Parses a vite-plus test summary into the full-gate pass/fail counts an assurance receipt records. Its only production caller was the deleted run-mvp-assurance.ts (#9325). The replacement assurance runner for the surviving admitted specs is the successor caller."
2447 2475
    }
2448 2476
  ]
2449 2477
}

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