Give the coder memory: children inherit it, and it learns from them

c78a82ec836f · AtlantisPleb · · parent 8fefc2e8d371

Give the coder memory: children inherit it, and it learns from them

The live half of the agent-continuity program (project 15, issues
#226/#227): every coder session now carries a CoderMemory — a local
append-only ledger of signed NIP-AE-shaped engrams under
~/.openagents/memory. When the session delegates, the child's prompt
gains a bounded advisory block of the parent's distilled heuristics
(opaque refs, never the private bank); when a child completes, its
answer is harvested back through the redaction gate into the ledger.
Reads re-verify every event id and supersession chain — a chain that
does not verify is not memory. One dreaming pass per recall clusters
harvested findings and promotes strong ones through the reviewed
pattern layer.

The CLI ships standalone, so the memory modules are vendored into
src/memory/ from the canonical packages by scripts/vendor-memory.mjs,
with a drift-guard test that re-runs the transform and fails on any
divergence, and a .prettierignore keeping canonical formatting.

Found and fixed on the way: the ATIF redaction boundary did not match
the oa_pat_ prefix real OpenAgents personal access tokens use — the
coder-memory refusal test caught it; the pattern and a fixture now
cover it.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/atif/src/redaction.test.ts
  • modified packages/atif/src/redaction.ts
  • added packages/openagents-cli/.prettierignore
  • added packages/openagents-cli/scripts/vendor-memory.mjs
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/src/coder-memory.ts
  • added packages/openagents-cli/src/memory/bip39-wordlist.ts
  • added packages/openagents-cli/src/memory/canonical.ts
  • added packages/openagents-cli/src/memory/consolidation.ts
  • added packages/openagents-cli/src/memory/engram.ts
  • added packages/openagents-cli/src/memory/index.ts
  • added packages/openagents-cli/src/memory/pattern.ts
  • added packages/openagents-cli/src/memory/ranking.ts
  • added packages/openagents-cli/src/memory/redaction.ts
  • added packages/openagents-cli/src/memory/refs.ts
  • added packages/openagents-cli/src/memory/sha256.ts
  • added packages/openagents-cli/src/memory/subagent-memory.ts
  • added packages/openagents-cli/test/coder-memory.test.ts
  • added packages/openagents-cli/test/vendored-memory-drift.test.ts
  • modified scripts/uncalled-production-symbol-baseline.json

Diff

23 files changed, +2765 -37

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": 2462,
7
    "filesScanned": 2464,
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:0f196ae412fa4a0b3d7028db3535205a6051cd8c714835d7baf61e7fab70c6b5",
4
  "sourceDigest": "sha256:432d55f76eea481e2c0451fb1f2511fa7e93ec7ac0f709ccb0aaed7ed1fa912c",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (59 tracked test files)"
1879
          "ref": "packages/openagents-cli (61 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/atif/src/redaction.test.ts modified +6

@@ -65,6 +65,12 @@ const SECRET_FIXTURES: ReadonlyArray<{

65 65
    leak: "oa_live_abcdef0123456789",
66 66
    category: "oa_token",
67 67
  },
68
  {
69
    label: "oa_pat_ personal access token",
70
    raw: "the token is oa_pat_abc123def456ghi789jkl012 here",
71
    leak: "oa_pat_abc123def456",
72
    category: "oa_token",
73
  },
68 74
  {
69 75
    label: "X verification code",
70 76
    raw: "Code: oa-x-9f2bc-defG",
packages/atif/src/redaction.ts modified +1 -1

@@ -264,7 +264,7 @@ const RULES: ReadonlyArray<Rule> = [

264 264
  },
265 265
  {
266 266
    category: "oa_token",
267
    pattern: /\boa_(?:live|test|sk|key|secret|tok|token)?_?[A-Za-z0-9]{12,}\b/g,
267
    pattern: /\boa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}\b/g,
268 268
    replace: () => tag("oa_token"),
269 269
  },
270 270
  {
packages/openagents-cli/.prettierignore added +4

@@ -0,0 +1,4 @@

1
# Vendored from packages/agent-experience-memory and packages/atif;
2
# kept byte-identical to the canonical sources (see scripts/vendor-memory.mjs),
3
# so the canonical formatting wins over this package's formatter.
4
src/memory/
packages/openagents-cli/scripts/vendor-memory.mjs added +95

@@ -0,0 +1,95 @@

1
#!/usr/bin/env node
2
/**
3
 * Vendor the agent-experience-memory modules the coder's memory runs on.
4
 *
5
 * The CLI ships standalone — `pnpm pack` and a global install with no
6
 * workspace around it — so it cannot carry a runtime dependency on the
7
 * unpublished workspace package. The canonical sources stay in
8
 * `packages/agent-experience-memory` and `packages/atif`; this script copies
9
 * the exact closure into `src/memory/` with only import specifiers rewritten.
10
 * `test/vendored-memory-drift.test.ts` re-runs the same transform and fails
11
 * when the vendored tree no longer matches the canonical sources, so the two
12
 * copies cannot drift silently.
13
 *
14
 * Run from the package root: `node scripts/vendor-memory.mjs`
15
 */
16
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
import { dirname, join } from "node:path";
18
import { fileURLToPath } from "node:url";
19
20
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
21
const packagesRoot = join(packageRoot, "..");
22
23
/** source path (from packages/) → vendored name, with specifier rewrites. */
24
export const VENDORED = [
25
  ["atif/src/redaction.ts", "redaction.ts", [["./bip39-wordlist.ts", "./bip39-wordlist.js"]]],
26
  ["atif/src/bip39-wordlist.ts", "bip39-wordlist.ts", []],
27
  ["agent-experience-memory/src/internal/sha256.ts", "sha256.ts", []],
28
  ["agent-experience-memory/src/internal/canonical.ts", "canonical.ts", []],
29
  ["agent-experience-memory/src/contract/refs.ts", "refs.ts", []],
30
  ["agent-experience-memory/src/contract/pattern.ts", "pattern.ts", [["./refs.js", "./refs.js"]]],
31
  ["agent-experience-memory/src/ranking.ts", "ranking.ts", []],
32
  [
33
    "agent-experience-memory/src/engram.ts",
34
    "engram.ts",
35
    [
36
      ["@openagentsinc/atif/redaction", "./redaction.js"],
37
      ["./internal/canonical.js", "./canonical.js"],
38
      ["./internal/sha256.js", "./sha256.js"],
39
    ],
40
  ],
41
  [
42
    "agent-experience-memory/src/consolidation.ts",
43
    "consolidation.ts",
44
    [
45
      ["./contract/pattern.js", "./pattern.js"],
46
      ["./contract/refs.js", "./refs.js"],
47
      ["./internal/canonical.js", "./canonical.js"],
48
      ["./internal/sha256.js", "./sha256.js"],
49
    ],
50
  ],
51
  [
52
    "agent-experience-memory/src/subagent-memory.ts",
53
    "subagent-memory.ts",
54
    [
55
      ["./contract/pattern.js", "./pattern.js"],
56
      ["./contract/refs.js", "./refs.js"],
57
      ["./internal/canonical.js", "./canonical.js"],
58
      ["./internal/sha256.js", "./sha256.js"],
59
    ],
60
  ],
61
];
62
63
const HEADER = (source) =>
64
  `// Vendored from packages/${source} by scripts/vendor-memory.mjs — do not edit here.\n` +
65
  `// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy\n` +
66
  `// no longer matches the canonical source.\n`;
67
68
/** The vendored text for one entry, derived from the canonical source. */
69
export const renderVendored = (source, rewrites) => {
70
  let text = readFileSync(join(packagesRoot, source), "utf8");
71
  for (const [from, to] of rewrites) {
72
    text = text.replaceAll(`"${from}"`, `"${to}"`);
73
  }
74
  return HEADER(source) + text;
75
};
76
77
const BARREL = `// The barrel coder-memory.ts consumes; regenerated by scripts/vendor-memory.mjs.
78
export * from "./engram.js";
79
export * from "./consolidation.js";
80
export * from "./subagent-memory.js";
81
`;
82
83
export const writeAll = () => {
84
  const out = join(packageRoot, "src", "memory");
85
  mkdirSync(out, { recursive: true });
86
  for (const [source, name, rewrites] of VENDORED) {
87
    writeFileSync(join(out, name), renderVendored(source, rewrites));
88
  }
89
  writeFileSync(join(out, "index.ts"), BARREL);
90
};
91
92
if (process.argv[1] === fileURLToPath(import.meta.url)) {
93
  writeAll();
94
  console.log(`vendored ${VENDORED.length} modules into src/memory/`);
95
}
packages/openagents-cli/src/cli.ts modified +9

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

46 46
  selfChildLane,
47 47
} from "./coder-delegate.js";
48 48
import { fleetPlainLines } from "./coder-fleet.js";
49
import { CoderMemory } from "./coder-memory.js";
49 50
import { runCoderPlain } from "./coder-plain.js";
50 51
import type { CoderDelegation } from "./coder-session.js";
51 52
import type { ReplySource } from "./coder-session.js";

@@ -1693,6 +1694,11 @@ async function buildDelegation(options: {

1693 1694
  // children of two models still render as one fleet and stop together.
1694 1695
  const registry = new CoderTaskRegistry();
1695 1696
1697
  // One memory for the session too: children inherit the parent's distilled
1698
  // heuristics in their prompts, and what they report is harvested back into
1699
  // the local engram ledger (openagents.com project 15, issues #226/#227).
1700
  const memory = new CoderMemory({ projectScope: `project:${options.cwd}` });
1701
1696 1702
  // Labelled by the name that was asked for. A caller who names `ox-alpha` and
1697 1703
  // is answered `x-preview-f-free` cannot tell whether the request was honoured
1698 1704
  // or silently fell back, and one that was asked exactly this said so rather

@@ -1716,6 +1722,7 @@ async function buildDelegation(options: {

1716 1722
      fleet: new DelegateFleet(registry, harness, {
1717 1723
        maxConcurrent: Math.max(1, options.concurrency),
1718 1724
        cwd: options.cwd,
1725
        memory,
1719 1726
      }),
1720 1727
      label: `${harness.agent} (${childLaneName(harness.model)})`,
1721 1728
    };

@@ -1774,6 +1781,7 @@ async function buildDelegation(options: {

1774 1781
    const fleet = new DelegateFleet(registry, harness, {
1775 1782
      maxConcurrent: Math.max(1, options.concurrency),
1776 1783
      cwd: options.cwd,
1784
      memory,
1777 1785
    });
1778 1786
1779 1787
    return {

@@ -1834,6 +1842,7 @@ async function buildDelegation(options: {

1834 1842
  const fleet = new DelegateFleet(registry, harness, {
1835 1843
    maxConcurrent: Math.max(1, options.concurrency),
1836 1844
    cwd: options.cwd,
1845
    memory,
1837 1846
  });
1838 1847
  return {
1839 1848
    delegation: {
packages/openagents-cli/src/coder-delegate.ts modified +28 -4

@@ -563,7 +563,8 @@ export const CHILD_LANES: ReadonlyArray<ChildLane> = [

563 563
    name: "ox-alpha",
564 564
    harness: "openagents (this process, one `shell` tool)",
565 565
    model: "Ox Alpha",
566
    served: "the OpenAgents inference proxy, routed to OpenRouter `stealth/ox-alpha`, on this session's thread grant",
566
    served:
567
      "the OpenAgents inference proxy, routed to OpenRouter `stealth/ox-alpha`, on this session's thread grant",
567 568
    bestFor: "work whose shape is the question: design, architecture, an open-ended refactor",
568 569
  },
569 570
  {

@@ -574,7 +575,8 @@ export const CHILD_LANES: ReadonlyArray<ChildLane> = [

574 575
    // implies two models sends a caller here for the wrong reason.
575 576
    model: "Ox Alpha — the same model as `ox-alpha`, under opencode's name for it",
576 577
    served: "OpenCode Zen, on this machine's opencode credential",
577
    bestFor: "the same work as `ox-alpha`, when you want opencode's harness and tools instead of ours",
578
    bestFor:
579
      "the same work as `ox-alpha`, when you want opencode's harness and tools instead of ours",
578 580
  },
579 581
  {
580 582
    name: "gemini",

@@ -906,6 +908,8 @@ function stripAnsi(text: string): string {

906 908
  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
907 909
}
908 910
911
import type { CoderDelegationMemory } from "./coder-memory.js";
912
909 913
export interface DelegateFleetOptions {
910 914
  /**
911 915
   * How many children may run at once.

@@ -921,6 +925,12 @@ export interface DelegateFleetOptions {

921 925
  readonly transcriptDirectory?: string | undefined;
922 926
  /** The console's own directory, used when a request names none. */
923 927
  readonly cwd?: string | undefined;
928
  /**
929
   * The parent's memory, when the session carries one. `inherit` contributes
930
   * an advisory block to each child prompt; `harvest` records a completed
931
   * child's answer. Both are best-effort: memory never breaks a delegation.
932
   */
933
  readonly memory?: CoderDelegationMemory | undefined;
924 934
}
925 935
926 936
/**

@@ -974,12 +984,21 @@ export class DelegateFleet {

974 984
    }
975 985
976 986
    const cwd = request.cwd ?? this.options.cwd ?? process.cwd();
987
    // Inherited memory rides the prompt itself, so every harness — a shell
988
    // child, Devin over ACP, an opencode lane — carries it the same way.
989
    let inherited = "";
990
    try {
991
      inherited = this.options.memory?.inherit(request.prompt) ?? "";
992
    } catch {
993
      inherited = "";
994
    }
995
    const prompt = inherited.length > 0 ? `${request.prompt}\n\n${inherited}` : request.prompt;
977 996
    // Registered before it can queue, so a child waiting for a slot is visible
978 997
    // as `pending` rather than as nothing at all.
979 998
    const task = this.registry.register({
980 999
      id: this.mintId(),
981 1000
      description: request.description.trim().length > 0 ? request.description : "delegated task",
982
      prompt: request.prompt,
1001
      prompt,
983 1002
      agent: this.harness.agent,
984 1003
      model: this.harness.model,
985 1004
      cwd,

@@ -998,7 +1017,7 @@ export class DelegateFleet {

998 1017
999 1018
    this.active += 1;
1000 1019
    try {
1001
      return await this.execute(task.id, request, cwd);
1020
      return await this.execute(task.id, { ...request, prompt }, cwd);
1002 1021
    } finally {
1003 1022
      this.active -= 1;
1004 1023
      this.waiting.shift()?.();

@@ -1067,6 +1086,11 @@ export class DelegateFleet {

1067 1086
      const failure = reported ?? thrown;
1068 1087
      if (failure === undefined) {
1069 1088
        this.registry.complete(id, text);
1089
        try {
1090
          this.options.memory?.harvest(id, text);
1091
        } catch {
1092
          // Memory must never turn a completed child into a failed one.
1093
        }
1070 1094
        return { status: "completed", taskId: id, result: text };
1071 1095
      }
1072 1096
packages/openagents-cli/src/coder-memory.ts added +307

@@ -0,0 +1,307 @@

1
import { createHmac, randomBytes } from "node:crypto";
2
import {
3
  appendFileSync,
4
  chmodSync,
5
  existsSync,
6
  mkdirSync,
7
  readFileSync,
8
  writeFileSync,
9
} from "node:fs";
10
import { homedir } from "node:os";
11
import { join } from "node:path";
12
13
import {
14
  buildEngramBody,
15
  buildEngramEvent,
16
  buildSubagentMemoryContext,
17
  consolidateEpisodes,
18
  engramContentDigest,
19
  guardEngramContent,
20
  harvestSubagentOutcome,
21
  ledgerEntriesAsHeuristics,
22
  promoteHeuristicToPattern,
23
  signSupersedingEngram,
24
  verifyEngramEventId,
25
  verifySupersessionChain,
26
  HarvestedLedgerEntry,
27
  type EngramEvent,
28
  type EngramBody,
29
  type ParentHeuristic,
30
} from "./memory/index.js";
31
import { Schema as S } from "effect";
32
33
/**
34
 * The coder's own memory: a local, append-only ledger of signed engrams.
35
 *
36
 * This is the live wiring of the agent-continuity program (openagents.com
37
 * project 15): what a delegated child learns is harvested into the parent's
38
 * ledger (#227), and what the parent knows seeds the next child's prompt as a
39
 * bounded advisory block (#226). Between sessions the ledger is the memory —
40
 * one JSONL file of NIP-AE-shaped engram events under `~/.openagents/memory`,
41
 * every value through the hard-unsafe redaction gate before it is signed, and
42
 * every read re-verifying event ids and supersession chains.
43
 *
44
 * The signature is a local HMAC over the canonical event id with a key held at
45
 * `~/.openagents/memory/signing-key` (0600) — integrity against accidental
46
 * edits and a stable authorship mark for this machine, not a Nostr Schnorr
47
 * signature. When the relay sync adapter (#222) lands, the same events re-sign
48
 * under a real Nostr key; the body and chain shapes are already NIP-AE.
49
 */
50
51
const decodeLedgerEntry = S.decodeUnknownSync(HarvestedLedgerEntry);
52
53
/** What the delegate fleet asks of memory, kept small so the fleet stays dumb. */
54
export interface CoderDelegationMemory {
55
  /** The advisory block to append to a child prompt, or empty when nothing qualifies. */
56
  inherit(taskText: string): string;
57
  /** Record a completed child's answer into the ledger. Never throws. */
58
  harvest(childId: string, answer: string): void;
59
}
60
61
export interface CoderMemoryOptions {
62
  /** Ledger directory. Default `~/.openagents/memory`. */
63
  readonly directory?: string;
64
  /** The project scope written into harvested entries. Default derived from cwd. */
65
  readonly projectScope?: string;
66
  /** Epoch-milliseconds clock, injectable for tests. */
67
  readonly now?: () => number;
68
}
69
70
const OWNER_SCOPE = "owner:local";
71
72
const sanitizeScope = (value: string): string => {
73
  const cleaned = value.replace(/[^A-Za-z0-9._:/-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "");
74
  return cleaned.length > 0 ? cleaned.slice(0, 200) : "project";
75
};
76
77
export class CoderMemory implements CoderDelegationMemory {
78
  private readonly directory: string;
79
  private readonly ledgerPath: string;
80
  private readonly keyPath: string;
81
  private readonly projectScope: string;
82
  private readonly now: () => number;
83
  private key: Buffer | undefined;
84
85
  constructor(options: CoderMemoryOptions = {}) {
86
    this.directory = options.directory ?? join(homedir(), ".openagents", "memory");
87
    this.ledgerPath = join(this.directory, "engrams.jsonl");
88
    this.keyPath = join(this.directory, "signing-key");
89
    this.projectScope = sanitizeScope(options.projectScope ?? `project:${process.cwd()}`);
90
    this.now = options.now ?? Date.now;
91
  }
92
93
  /** The local signing key, created on first use. */
94
  private signingKey(): Buffer {
95
    if (this.key !== undefined) return this.key;
96
    mkdirSync(this.directory, { recursive: true, mode: 0o700 });
97
    if (!existsSync(this.keyPath)) {
98
      writeFileSync(this.keyPath, randomBytes(32).toString("hex"), { mode: 0o600 });
99
      chmodSync(this.keyPath, 0o600);
100
    }
101
    this.key = Buffer.from(readFileSync(this.keyPath, "utf8").trim(), "hex");
102
    return this.key;
103
  }
104
105
  private signer(): { pubkey: string; sign: (eventId: string) => string } {
106
    const key = this.signingKey();
107
    // A stable 64-hex identity derived from the key, so events from the same
108
    // machine share an author without the key itself ever leaving the file.
109
    const pubkey = createHmac("sha256", key).update("openagents.coder-memory.pubkey").digest("hex");
110
    return {
111
      pubkey,
112
      sign: (eventId: string) => createHmac("sha256", key).update(eventId).digest("hex"),
113
    };
114
  }
115
116
  /**
117
   * Guard, build, sign, and append one engram. Returns the event, or undefined
118
   * when the value is hard-unsafe (credential-shaped material never persists).
119
   */
120
  record(slug: string, value: string | null, entityId: string): EngramEvent | undefined {
121
    const verdict = guardEngramContent(value);
122
    if (!verdict.storable) return undefined;
123
    const body = buildEngramBody(slug, verdict.redacted, {
124
      admission: "admitted",
125
      entityId,
126
      contentDigest: engramContentDigest(verdict.redacted),
127
      sourceEventRefs: [],
128
      relations: [],
129
      derivedFromSlugs: [],
130
    });
131
    const { pubkey, sign } = this.signer();
132
    const event = buildEngramEvent(
133
      pubkey,
134
      Math.floor(this.now() / 1000),
135
      slug,
136
      JSON.stringify(body),
137
      sign,
138
    );
139
    appendFileSync(this.ledgerPath, `${JSON.stringify(event)}\n`, { mode: 0o600 });
140
    return event;
141
  }
142
143
  /**
144
   * Correct a remembered value: append a superseding engram referencing the
145
   * prior event. The prior event stays in the ledger; reads resolve the chain.
146
   * Pass `null` to tombstone the slug.
147
   */
148
  correct(slug: string, newValue: string | null): EngramEvent | undefined {
149
    const chain = this.chains().get(slug);
150
    const prior = chain?.[chain.length - 1];
151
    if (prior === undefined) return undefined;
152
    const verdict = guardEngramContent(newValue);
153
    if (!verdict.storable) return undefined;
154
    const { pubkey, sign } = this.signer();
155
    const event = signSupersedingEngram(
156
      prior,
157
      verdict.redacted,
158
      Math.max(Math.floor(this.now() / 1000), prior.created_at + 1),
159
      pubkey,
160
      sign,
161
    );
162
    appendFileSync(this.ledgerPath, `${JSON.stringify(event)}\n`, { mode: 0o600 });
163
    return event;
164
  }
165
166
  /** All ledger events grouped per slug in append order, invalid lines dropped. */
167
  private chains(): Map<string, Array<EngramEvent>> {
168
    const chains = new Map<string, Array<EngramEvent>>();
169
    if (!existsSync(this.ledgerPath)) return chains;
170
    for (const line of readFileSync(this.ledgerPath, "utf8").split("\n")) {
171
      if (line.trim().length === 0) continue;
172
      let event: EngramEvent;
173
      try {
174
        event = JSON.parse(line) as EngramEvent;
175
      } catch {
176
        continue;
177
      }
178
      if (!verifyEngramEventId(event)) continue;
179
      const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
180
      if (dTag === undefined) continue;
181
      const chain = chains.get(dTag) ?? [];
182
      chain.push(event);
183
      chains.set(dTag, chain);
184
    }
185
    return chains;
186
  }
187
188
  /**
189
   * The live value per slug: each chain verified end to end, the newest body
190
   * winning, tombstoned and broken chains dropped entirely — a chain that does
191
   * not verify is not memory, it is noise.
192
   */
193
  bodies(): ReadonlyArray<EngramBody> {
194
    return this.living().map((entry) => entry.body);
195
  }
196
197
  private living(): ReadonlyArray<{ body: EngramBody; createdAtMs: number }> {
198
    const out: Array<{ body: EngramBody; createdAtMs: number }> = [];
199
    for (const chain of this.chains().values()) {
200
      if (!verifySupersessionChain(chain)) continue;
201
      const last = chain[chain.length - 1];
202
      if (last === undefined) continue;
203
      let body: EngramBody;
204
      try {
205
        body = JSON.parse(last.content) as EngramBody;
206
      } catch {
207
        continue;
208
      }
209
      if (body.value === null) continue;
210
      out.push({ body, createdAtMs: last.created_at * 1000 });
211
    }
212
    return out;
213
  }
214
215
  /**
216
   * The harvested ledger entries currently alive in the engram stream.
217
   *
218
   * The ledger stores only the redacted finding text — a typed entry carries
219
   * 64-hex digests, and the redaction gate rightly refuses hex of that shape
220
   * as key-shaped material — so the typed entry is rebuilt deterministically
221
   * from the finding, the child id, and the event time on every read.
222
   */
223
  entries(): ReadonlyArray<HarvestedLedgerEntry> {
224
    const entries: Array<HarvestedLedgerEntry> = [];
225
    for (const { body, createdAtMs } of this.living()) {
226
      if (!body.slug.startsWith("harvest/") || body.value === null) continue;
227
      const rebuilt = harvestSubagentOutcome({
228
        ownerScope: OWNER_SCOPE,
229
        projectScope: this.projectScope,
230
        outcome: {
231
          childId: body.openagents.entityId,
232
          summary: body.value,
233
          completedAtMs: createdAtMs,
234
        },
235
      });
236
      for (const entry of rebuilt.entries) {
237
        entries.push(decodeLedgerEntry(entry));
238
      }
239
    }
240
    return entries;
241
  }
242
243
  /**
244
   * The parent's heuristics: harvested findings at the harvest confidence
245
   * floor, plus what one dreaming pass distills from them — clusters of
246
   * related findings synthesized and, where support is strong enough,
247
   * promoted through the reviewed pattern layer at higher confidence.
248
   */
249
  heuristics(): ReadonlyArray<ParentHeuristic> {
250
    const entries = this.entries();
251
    const base = ledgerEntriesAsHeuristics(entries);
252
    const consolidated = consolidateEpisodes({
253
      ownerScope: OWNER_SCOPE,
254
      projectScope: this.projectScope,
255
      episodes: entries.map((entry) => ({
256
        ref: entry.entryId,
257
        text: entry.finding,
258
        observedAtMs: Date.parse(entry.completedAt),
259
      })),
260
      nowMs: this.now(),
261
    });
262
    const promoted = consolidated.heuristics.map((heuristic): ParentHeuristic => {
263
      const pattern = promoteHeuristicToPattern(
264
        heuristic,
265
        "delegating a coding task like the ones this heuristic came from",
266
      );
267
      return {
268
        ref: pattern.patternRef,
269
        text: heuristic.heuristic,
270
        confidence: heuristic.confidence,
271
      };
272
    });
273
    return [...promoted, ...base];
274
  }
275
276
  inherit(taskText: string): string {
277
    try {
278
      const heuristics = this.heuristics();
279
      if (heuristics.length === 0) return "";
280
      return buildSubagentMemoryContext({ heuristics, taskText }).block;
281
    } catch {
282
      // Memory must never break a delegation.
283
      return "";
284
    }
285
  }
286
287
  harvest(childId: string, answer: string): void {
288
    try {
289
      const trimmed = answer.trim();
290
      if (trimmed.length === 0) return;
291
      const harvested = harvestSubagentOutcome({
292
        ownerScope: OWNER_SCOPE,
293
        projectScope: this.projectScope,
294
        outcome: {
295
          childId,
296
          summary: trimmed.slice(0, 1000),
297
          completedAtMs: this.now(),
298
        },
299
      });
300
      for (const entry of harvested.entries) {
301
        this.record(`harvest/${entry.digest.slice(7, 19)}`, entry.finding, entry.childId);
302
      }
303
    } catch {
304
      // Memory must never break a delegation.
305
    }
306
  }
307
}
packages/openagents-cli/src/memory/bip39-wordlist.ts added +267

@@ -0,0 +1,267 @@

1
// Vendored from packages/atif/src/bip39-wordlist.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
// BIP39 English wordlist (2048 words), embedded so the mnemonic redactor can
5
// verify a candidate word-run is an ACTUAL seed phrase (every word in the list)
6
// rather than ordinary prose. Kept dependency-free on purpose: this is static
7
// reference data, not a runtime dependency. Source: the canonical BIP39 English
8
// wordlist (as shipped by @scure/bip39). Do not reorder or edit by hand.
9
10
export const BIP39_ENGLISH_WORDS: ReadonlySet<string> = new Set([
11
  "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
12
  "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
13
  "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
14
  "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
15
  "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
16
  "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
17
  "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
18
  "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
19
  "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
20
  "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
21
  "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
22
  "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
23
  "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
24
  "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
25
  "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
26
  "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
27
  "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
28
  "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
29
  "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
30
  "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
31
  "beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
32
  "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
33
  "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
34
  "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
35
  "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
36
  "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
37
  "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
38
  "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
39
  "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
40
  "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
41
  "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
42
  "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
43
  "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
44
  "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
45
  "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
46
  "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
47
  "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
48
  "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
49
  "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
50
  "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
51
  "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
52
  "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
53
  "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
54
  "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
55
  "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
56
  "code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
57
  "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
58
  "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
59
  "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
60
  "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
61
  "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
62
  "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
63
  "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
64
  "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
65
  "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
66
  "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
67
  "day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
68
  "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
69
  "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
70
  "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
71
  "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
72
  "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
73
  "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
74
  "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
75
  "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
76
  "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
77
  "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
78
  "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
79
  "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
80
  "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
81
  "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
82
  "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
83
  "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
84
  "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
85
  "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
86
  "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
87
  "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
88
  "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
89
  "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
90
  "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
91
  "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
92
  "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
93
  "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
94
  "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
95
  "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
96
  "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
97
  "figure", "file", "film", "filter", "final", "find", "fine", "finger",
98
  "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
99
  "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
100
  "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
101
  "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
102
  "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
103
  "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
104
  "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
105
  "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
106
  "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
107
  "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
108
  "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
109
  "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
110
  "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
111
  "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
112
  "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
113
  "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
114
  "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
115
  "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
116
  "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
117
  "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
118
  "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
119
  "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
120
  "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
121
  "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
122
  "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
123
  "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
124
  "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
125
  "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
126
  "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
127
  "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
128
  "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
129
  "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
130
  "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
131
  "job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
132
  "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
133
  "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
134
  "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
135
  "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
136
  "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
137
  "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
138
  "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
139
  "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
140
  "library", "license", "life", "lift", "light", "like", "limb", "limit",
141
  "link", "lion", "liquid", "list", "little", "live", "lizard", "load",
142
  "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
143
  "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
144
  "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
145
  "maid", "mail", "main", "major", "make", "mammal", "man", "manage",
146
  "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
147
  "marine", "market", "marriage", "mask", "mass", "master", "match", "material",
148
  "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
149
  "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
150
  "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
151
  "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
152
  "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
153
  "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
154
  "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
155
  "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
156
  "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
157
  "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
158
  "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
159
  "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
160
  "never", "news", "next", "nice", "night", "noble", "noise", "nominee",
161
  "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
162
  "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
163
  "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
164
  "october", "odor", "off", "offer", "office", "often", "oil", "okay",
165
  "old", "olive", "olympic", "omit", "once", "one", "onion", "online",
166
  "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
167
  "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
168
  "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
169
  "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
170
  "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
171
  "parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
172
  "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
173
  "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
174
  "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
175
  "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
176
  "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
177
  "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
178
  "poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
179
  "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
180
  "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
181
  "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
182
  "prison", "private", "prize", "problem", "process", "produce", "profit", "program",
183
  "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
184
  "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
185
  "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
186
  "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
187
  "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
188
  "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
189
  "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
190
  "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
191
  "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
192
  "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
193
  "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
194
  "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
195
  "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
196
  "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
197
  "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
198
  "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
199
  "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
200
  "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
201
  "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
202
  "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
203
  "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
204
  "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
205
  "search", "season", "seat", "second", "secret", "section", "security", "seed",
206
  "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
207
  "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
208
  "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
209
  "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
210
  "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
211
  "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
212
  "simple", "since", "sing", "siren", "sister", "situate", "six", "size",
213
  "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
214
  "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
215
  "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
216
  "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
217
  "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
218
  "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
219
  "source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
220
  "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
221
  "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
222
  "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
223
  "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
224
  "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
225
  "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
226
  "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
227
  "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
228
  "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
229
  "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
230
  "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
231
  "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
232
  "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
233
  "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
234
  "tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
235
  "theme", "then", "theory", "there", "they", "thing", "this", "thought",
236
  "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
237
  "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
238
  "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
239
  "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
240
  "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
241
  "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
242
  "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
243
  "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
244
  "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
245
  "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
246
  "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
247
  "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
248
  "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
249
  "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
250
  "upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
251
  "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
252
  "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
253
  "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
254
  "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
255
  "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
256
  "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
257
  "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
258
  "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
259
  "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
260
  "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
261
  "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
262
  "wild", "will", "win", "window", "wine", "wing", "wink", "winner",
263
  "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
264
  "wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
265
  "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
266
  "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
267
])
packages/openagents-cli/src/memory/canonical.ts added +33

@@ -0,0 +1,33 @@

1
// Vendored from packages/agent-experience-memory/src/internal/canonical.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
import { sha256Hex } from "./sha256.js";
5
6
/**
7
 * Deterministic canonical JSON: object keys are sorted, arrays keep order.
8
 *
9
 * Two structurally equal values always serialize to the same bytes, so a digest
10
 * over the canonical form is a stable content address. This mirrors the frozen
11
 * `compileThreadExportArtifact` canonicalization in `agent-runtime-schema` so
12
 * the DSE artifact digest and the frozen export digest share one rule. The input
13
 * is `unknown` because callers pass Effect-encoded structs at a serialization
14
 * boundary; the function walks JSON-shaped values and leaves primitives intact.
15
 */
16
const compareStrings = (left: string, right: string): number =>
17
  left < right ? -1 : left > right ? 1 : 0;
18
19
const canonicalize = (value: unknown): unknown => {
20
  if (Array.isArray(value)) return value.map(canonicalize);
21
  if (typeof value !== "object" || value === null) return value;
22
  return Object.fromEntries(
23
    Object.entries(value)
24
      .sort(([left], [right]) => compareStrings(left, right))
25
      .map(([key, child]) => [key, canonicalize(child)]),
26
  );
27
};
28
29
/** Serialize a value to its deterministic canonical string form. */
30
export const canonicalStringify = (value: unknown): string => JSON.stringify(canonicalize(value));
31
32
/** The lowercase-hex SHA-256 digest of the canonical serialization of `value`. */
33
export const canonicalDigest = (value: unknown): string => sha256Hex(canonicalStringify(value));
packages/openagents-cli/src/memory/consolidation.ts added +286

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

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

@@ -0,0 +1,390 @@

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

@@ -0,0 +1,4 @@

1
// The barrel coder-memory.ts consumes; regenerated by scripts/vendor-memory.mjs.
2
export * from "./engram.js";
3
export * from "./consolidation.js";
4
export * from "./subagent-memory.js";
packages/openagents-cli/src/memory/pattern.ts added +47

@@ -0,0 +1,47 @@

1
// Vendored from packages/agent-experience-memory/src/contract/pattern.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
import { Schema as S } from "effect";
5
6
import { FactRef, MemoryTimestamp, OwnerScopeId, PatternRef, ProjectScopeId, Sha256Hex } from "./refs.js";
7
8
/**
9
 * The distilled global-pattern layer — the only genuinely new surface in AFS-10.
10
 *
11
 * A pattern names a recurring phenomenon distilled offline from the owner's own
12
 * consented per-case records. It carries supporting success and failure
13
 * references and an applicability bound, so recall returns a bounded slice
14
 * rather than the whole per-case bank (the MemoHarness global layer). A pattern
15
 * is derived from redacted per-case text only; it must carry no owner-private
16
 * raw content, and it inherits no access to the private cases that supported it.
17
 * It stays inside one owner scope; a cross-owner or cross-project pattern needs
18
 * a separate explicit scope and authority.
19
 */
20
export const GLOBAL_PATTERN_SCHEMA_LITERAL = "openagents.experience_pattern.v1" as const;
21
22
export const MAX_PATTERN_TEXT_CHARS = 1000;
23
24
const patternText = S.String.check(S.isMinLength(1), S.isMaxLength(MAX_PATTERN_TEXT_CHARS));
25
26
export const GlobalPattern = S.Struct({
27
  schema: S.Literal(GLOBAL_PATTERN_SCHEMA_LITERAL),
28
  patternRef: PatternRef,
29
  ownerScope: OwnerScopeId,
30
  projectScope: ProjectScopeId,
31
  /** The redacted phenomenon description. */
32
  phenomenon: patternText,
33
  /** The redacted applicability bound: when the pattern is expected to apply. */
34
  applicability: patternText,
35
  /** The redacted expected effect of acting on the pattern. */
36
  expectedEffect: patternText,
37
  supportSuccessRefs: S.Array(FactRef),
38
  supportFailureRefs: S.Array(FactRef),
39
  confidence: S.Number.check(S.isGreaterThanOrEqualTo(0), S.isLessThanOrEqualTo(1)),
40
  observedAt: MemoryTimestamp,
41
  /** The content address of the redacted pattern text. */
42
  digest: Sha256Hex,
43
});
44
export type GlobalPattern = typeof GlobalPattern.Type;
45
46
/** Trusted decoder for the distillation path (redaction happens before this). */
47
export const decodeGlobalPattern = S.decodeUnknownSync(GlobalPattern);
packages/openagents-cli/src/memory/ranking.ts added +121

@@ -0,0 +1,121 @@

1
// Vendored from packages/agent-experience-memory/src/ranking.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
/**
5
 * Neutral recall, ranking, and packing primitives.
6
 *
7
 * These reimplement the reviewed algorithm and tie-break ideas from the unwired
8
 * Pylon TAS kit (`semantic-retrieval.ts`, `session-memory.ts`,
9
 * `context-assembly.ts`). Per the AFS-10 packet, only the reviewed algorithm and
10
 * test ideas are reused; the TAS files carry no schema, persistence, consent,
11
 * delete, or owner-scope authority, so nothing is imported from them. Every
12
 * function here is pure and deterministic: equal inputs always give equal order,
13
 * so a recalled slice is auditable and stable.
14
 */
15
16
/** Cosine similarity of two equal-length vectors. Returns 0 for a zero vector. */
17
export const cosineSimilarity = (a: ReadonlyArray<number>, b: ReadonlyArray<number>): number => {
18
  if (a.length !== b.length) {
19
    throw new Error(`Embedding length mismatch: expected ${a.length}, received ${b.length}`);
20
  }
21
  let dot = 0;
22
  let aSq = 0;
23
  let bSq = 0;
24
  for (let index = 0; index < a.length; index += 1) {
25
    const av = a[index] ?? 0;
26
    const bv = b[index] ?? 0;
27
    dot += av * bv;
28
    aSq += av * av;
29
    bSq += bv * bv;
30
  }
31
  if (aSq === 0 || bSq === 0) return 0;
32
  return dot / Math.sqrt(aSq * bSq);
33
};
34
35
export type RankableItem<Ref extends string = string> = Readonly<{
36
  ref: Ref;
37
  embedding: ReadonlyArray<number>;
38
}>;
39
40
/** Top-k by cosine similarity, tie-broken by ref then original index for stability. */
41
export const topK = <Ref extends string>(
42
  query: ReadonlyArray<number>,
43
  items: ReadonlyArray<RankableItem<Ref>>,
44
  k: number,
45
): ReadonlyArray<RankableItem<Ref>> => {
46
  if (k <= 0) return [];
47
  return items
48
    .map((item, index) => ({ item, index, score: cosineSimilarity(query, item.embedding) }))
49
    .sort((left, right) => {
50
      if (right.score !== left.score) return right.score - left.score;
51
      if (left.item.ref < right.item.ref) return -1;
52
      if (left.item.ref > right.item.ref) return 1;
53
      return left.index - right.index;
54
    })
55
    .slice(0, Math.trunc(k))
56
    .map((ranked) => ranked.item);
57
};
58
59
export type SalienceItem<Ref extends string = string> = Readonly<{
60
  ref: Ref;
61
  salience: number;
62
  lastUsedAt: number;
63
}>;
64
65
/** Recall order without embeddings: salience plus a recency term, stable tie-break. */
66
export const recallOrderBySalience = <Ref extends string>(
67
  items: ReadonlyArray<SalienceItem<Ref>>,
68
  nowMs: number,
69
): ReadonlyArray<Ref> => {
70
  const recency = (item: SalienceItem<Ref>): number => 1 / (1 + Math.max(0, nowMs - item.lastUsedAt));
71
  return items
72
    .map((item, index) => ({ item, index, score: item.salience + recency(item) }))
73
    .sort((left, right) => {
74
      if (right.score !== left.score) return right.score - left.score;
75
      return left.index - right.index;
76
    })
77
    .map((ranked) => ranked.item.ref);
78
};
79
80
export type PackableItem<Ref extends string = string> = Readonly<{
81
  ref: Ref;
82
  priority: number;
83
  tokens: number;
84
  pinned?: boolean;
85
}>;
86
87
export type PackResult<Ref extends string = string> = Readonly<{
88
  included: ReadonlyArray<Ref>;
89
  dropped: ReadonlyArray<Ref>;
90
  usedTokens: number;
91
}>;
92
93
/** Token-budgeted packing: pinned items always fit first, then priority order. */
94
export const packWithinBudget = <Ref extends string>(
95
  items: ReadonlyArray<PackableItem<Ref>>,
96
  budgetTokens: number,
97
): PackResult<Ref> => {
98
  const pinned = items.filter((item) => item.pinned === true);
99
  const candidates = items
100
    .filter((item) => item.pinned !== true)
101
    .slice()
102
    .sort((left, right) => {
103
      if (right.priority !== left.priority) return right.priority - left.priority;
104
      return String(left.ref).localeCompare(String(right.ref));
105
    });
106
  const included: Ref[] = pinned.map((item) => item.ref);
107
  const dropped: Ref[] = [];
108
  let usedTokens = pinned.reduce((total, item) => total + item.tokens, 0);
109
  for (const item of candidates) {
110
    if (usedTokens + item.tokens <= budgetTokens) {
111
      included.push(item.ref);
112
      usedTokens += item.tokens;
113
    } else {
114
      dropped.push(item.ref);
115
    }
116
  }
117
  return { included, dropped, usedTokens };
118
};
119
120
/** A coarse, deterministic token estimate used only for budgeting the slice. */
121
export const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
packages/openagents-cli/src/memory/redaction.ts added +553

@@ -0,0 +1,553 @@

1
// Vendored from packages/atif/src/redaction.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
import { Context, Effect, Layer } from "effect"
5
6
import { BIP39_ENGLISH_WORDS } from "./bip39-wordlist.js"
7
8
export type RedactionCategory =
9
  | "private_key"
10
  | "mnemonic"
11
  | "jwt"
12
  | "bearer"
13
  | "provider_key"
14
  | "oa_agent_token"
15
  | "x_code"
16
  | "oa_token"
17
  | "aws_key"
18
  | "google_key"
19
  | "slack_token"
20
  | "github_token"
21
  | "owner_id"
22
  | "env_secret"
23
  | "wallet_or_payment"
24
  | "secrets_path"
25
  | "home_path"
26
  | "file_url"
27
  | "email"
28
  | "phone"
29
  | "ssn"
30
  | "date_of_birth"
31
  | "medical_record_id"
32
  | "ip"
33
  | "long_blob"
34
  | "username"
35
36
export type RedactOptions = Readonly<{
37
  usernames?: ReadonlyArray<string>
38
}>
39
40
export type RedactionSurface = "corpus_ingestion" | "trace_capture"
41
42
export type RegulatedVertical = "legal" | "health" | "other_regulated"
43
44
export type ExternalInferenceRedactionOptions = RedactOptions &
45
  Readonly<{
46
    surface: RedactionSurface
47
    regulatedVertical?: RegulatedVertical
48
  }>
49
50
export type RedactionReport = Readonly<{
51
  counts: Readonly<Record<string, number>>
52
  total: number
53
}>
54
55
export type RedactionResult<T> = Readonly<{
56
  value: T
57
  report: RedactionReport
58
}>
59
60
export type ExternalInferenceRedactionResult<T> = RedactionResult<T> &
61
  Readonly<{
62
    policy: Readonly<{
63
      serviceRef: typeof REDACTION_SERVICE_REF
64
      surface: RedactionSurface
65
      regulatedVertical?: RegulatedVertical
66
      appliedBeforeExternalInference: true
67
    }>
68
    safeForExternalInference: true
69
  }>
70
71
export type TraceRedactionCategory = RedactionCategory
72
export type TraceRedactionReport = RedactionReport
73
export type TraceRedactionResult<T> = RedactionResult<T>
74
75
export type TraceRedactorShape = Readonly<{
76
  redact: <T>(
77
    value: T,
78
    options?: RedactOptions,
79
  ) => Effect.Effect<RedactionResult<T>>
80
  redactString: (
81
    text: string,
82
    options?: RedactOptions,
83
  ) => Effect.Effect<RedactionResult<string>>
84
  redactText: (
85
    text: string,
86
    options?: RedactOptions,
87
  ) => Effect.Effect<RedactionResult<string>>
88
  redactForExternalInference: <T>(
89
    value: T,
90
    options: ExternalInferenceRedactionOptions,
91
  ) => Effect.Effect<ExternalInferenceRedactionResult<T>>
92
  redactTextForExternalInference: (
93
    text: string,
94
    options: ExternalInferenceRedactionOptions,
95
  ) => Effect.Effect<ExternalInferenceRedactionResult<string>>
96
  redactTrajectory: <T>(
97
    trajectory: T,
98
    options?: RedactOptions,
99
  ) => Effect.Effect<RedactionResult<T>>
100
}>
101
102
export const REDACTION_SERVICE_REF = "@openagentsinc/atif/redaction"
103
104
const ALLOWLIST_EXACT: ReadonlyArray<string> = ["openagents/khala"]
105
106
const ALLOWLIST_PATTERNS: ReadonlyArray<RegExp> = [
107
  /https?:\/\/openagents\.com\/[^\s"'`)<>]*/g,
108
  /https?:\/\/(?:www\.)?github\.com\/OpenAgentsInc\/[^\s"'`)<>]*/g,
109
  /#\d{1,6}\b/g,
110
]
111
112
const SENT_OPEN = "\uE000"
113
const SENT_CLOSE = "\uE001"
114
115
const escapeRegExp = (s: string): string =>
116
  s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
117
118
const tag = (cat: RedactionCategory): string => `[REDACTED:${cat}]`
119
120
const maskAllowlist = (
121
  text: string,
122
): { masked: string; originals: Array<string> } => {
123
  const originals: Array<string> = []
124
  let masked = text
125
  const stash = (m: string): string => {
126
    const idx = originals.length
127
    originals.push(m)
128
    return `${SENT_OPEN}${idx}${SENT_CLOSE}`
129
  }
130
131
  for (const exact of ALLOWLIST_EXACT) {
132
    masked = masked.replace(new RegExp(escapeRegExp(exact), "g"), m => stash(m))
133
  }
134
  for (const re of ALLOWLIST_PATTERNS) {
135
    re.lastIndex = 0
136
    masked = masked.replace(re, m => stash(m))
137
  }
138
139
  return { masked, originals }
140
}
141
142
const unmaskAllowlist = (
143
  masked: string,
144
  originals: ReadonlyArray<string>,
145
): string =>
146
  masked.replace(
147
    new RegExp(`${SENT_OPEN}(\\d+)${SENT_CLOSE}`, "g"),
148
    (_m, idx: string) => originals[Number(idx)] ?? "",
149
  )
150
151
type Rule = Readonly<{
152
  category: RedactionCategory
153
  pattern: RegExp
154
  replace: (match: string, ...groups: Array<string>) => string
155
}>
156
157
// A candidate BIP39 seed phrase is a run of 12/15/18/21/24 lowercase words.
158
// This regex only FINDS candidates cheaply; `mnemonicReplace` then confirms
159
// every word is an actual BIP39 word before redacting, so ordinary English
160
// prose (which is full of non-wordlist words like "the", "roadmap", "ide") is
161
// left intact. Real seed phrases are all-wordlist by definition and still redact.
162
const MNEMONIC = /\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b/g
163
164
// Shortest real BIP39 mnemonic. Runs of consecutive wordlist words below this
165
// length are treated as coincidental prose, not a seed phrase.
166
const MIN_MNEMONIC_WORDS = 12
167
168
/**
169
 * Redact only the ACTUAL seed phrase inside a shape-matched candidate: the
170
 * longest run of CONSECUTIVE BIP39 English words. If that run is at least a
171
 * 12-word mnemonic it is replaced with the tag while any surrounding prose words
172
 * (which are not in the wordlist) are preserved; otherwise the whole match is
173
 * returned unchanged. This keeps genuine seed phrases fully redacted — even when
174
 * they sit next to ordinary words — without redacting prose that merely happens
175
 * to be a run of short lowercase words.
176
 */
177
const mnemonicReplace = (match: string): string => {
178
  const words = match.split(" ")
179
  let bestStart = -1
180
  let bestLen = 0
181
  let curStart = 0
182
  let curLen = 0
183
  for (let i = 0; i < words.length; i += 1) {
184
    if (BIP39_ENGLISH_WORDS.has(words[i] as string)) {
185
      if (curLen === 0) curStart = i
186
      curLen += 1
187
      if (curLen > bestLen) {
188
        bestLen = curLen
189
        bestStart = curStart
190
      }
191
    } else {
192
      curLen = 0
193
    }
194
  }
195
  if (bestLen < MIN_MNEMONIC_WORDS) return match
196
  const before = words.slice(0, bestStart).join(" ")
197
  const after = words.slice(bestStart + bestLen).join(" ")
198
  return [before, tag("mnemonic"), after].filter(part => part !== "").join(" ")
199
}
200
201
const RULES: ReadonlyArray<Rule> = [
202
  {
203
    category: "private_key",
204
    pattern:
205
      /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
206
    replace: () => tag("private_key"),
207
  },
208
  { category: "mnemonic", pattern: MNEMONIC, replace: mnemonicReplace },
209
  {
210
    category: "jwt",
211
    pattern:
212
      /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\b/g,
213
    replace: () => tag("jwt"),
214
  },
215
  {
216
    category: "wallet_or_payment",
217
    pattern:
218
      /\b(?:lnbc[0-9][a-z0-9]{20,}|lntb[0-9][a-z0-9]{20,}|lno1[a-z0-9]{20,}|bc1[a-z0-9]{20,}|(?:xpub|ypub|zpub|tpub)[1-9A-HJ-NP-Za-km-z]{20,})\b/gi,
219
    replace: () => tag("wallet_or_payment"),
220
  },
221
  {
222
    category: "aws_key",
223
    pattern: /\bAKIA[0-9A-Z]{16}\b/g,
224
    replace: () => tag("aws_key"),
225
  },
226
  {
227
    category: "google_key",
228
    pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/g,
229
    replace: () => tag("google_key"),
230
  },
231
  {
232
    category: "slack_token",
233
    pattern: /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g,
234
    replace: () => tag("slack_token"),
235
  },
236
  {
237
    category: "github_token",
238
    pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
239
    replace: () => tag("github_token"),
240
  },
241
  {
242
    category: "bearer",
243
    pattern: /\b([Bb]earer)\s+[A-Za-z0-9._~+/=-]{8,}/g,
244
    replace: (_m, scheme: string) => `${scheme} ${tag("bearer")}`,
245
  },
246
  {
247
    category: "bearer",
248
    pattern:
249
      /\b(authorization)\s*[:=]\s*["']?(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}["']?/gi,
250
    replace: () => `authorization: ${tag("bearer")}`,
251
  },
252
  {
253
    category: "provider_key",
254
    pattern:
255
      /\b(?:sk-(?:or-|proj-|ant-)?[A-Za-z0-9_-]{8,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,})\b/g,
256
    replace: () => tag("provider_key"),
257
  },
258
  {
259
    category: "oa_agent_token",
260
    pattern: /\boa_agent_[A-Za-z0-9_-]{6,}\b/g,
261
    replace: () => tag("oa_agent_token"),
262
  },
263
  {
264
    category: "x_code",
265
    pattern: /\boa-x-[A-Za-z0-9_-]{4,}\b/g,
266
    replace: () => tag("x_code"),
267
  },
268
  {
269
    category: "oa_token",
270
    pattern: /\boa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}\b/g,
271
    replace: () => tag("oa_token"),
272
  },
273
  {
274
    category: "owner_id",
275
    pattern: /\b(github|gh|x|twitter|discord|telegram|nostr):\d{3,}\b/gi,
276
    replace: (_m, provider: string) => `${provider}:${tag("owner_id")}`,
277
  },
278
  {
279
    category: "env_secret",
280
    pattern:
281
      /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|MNEMONIC|SEED|API|BEARER|CREDENTIAL|PRIVATE)[A-Z0-9_]*)\s*=\s*["']?([^\s"'#]+)["']?/g,
282
    replace: (_m, key: string) => `${key}=${tag("env_secret")}`,
283
  },
284
  {
285
    category: "secrets_path",
286
    pattern: /(?:\.{1,2}\/)?\.secrets\/[^\s"'`)<>]+/g,
287
    replace: () => tag("secrets_path"),
288
  },
289
  {
290
    category: "file_url",
291
    pattern: /\bfile:\/\/[^\s"'`)<>]*/g,
292
    replace: () => tag("file_url"),
293
  },
294
  {
295
    category: "home_path",
296
    pattern: /\/Users\/[^\s"'`)<>]*/g,
297
    replace: () => tag("home_path"),
298
  },
299
  {
300
    category: "home_path",
301
    pattern: /\/home\/[^\s"'`)<>]*/g,
302
    replace: () => tag("home_path"),
303
  },
304
  {
305
    category: "home_path",
306
    pattern: /-Users-[^/\s"'`)<>-]+-/g,
307
    replace: () => "-Users-[REDACTED:home]-",
308
  },
309
  {
310
    category: "email",
311
    pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
312
    replace: () => tag("email"),
313
  },
314
  {
315
    category: "ssn",
316
    pattern: /\b(?:SSN|social security(?: number)?)\s*[:#=]?\s*\d{3}-\d{2}-\d{4}\b/gi,
317
    replace: () => `SSN ${tag("ssn")}`,
318
  },
319
  {
320
    category: "date_of_birth",
321
    pattern:
322
      /\b(?:DOB|date of birth|birth date)\s*[:#=]?\s*(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|[A-Z][a-z]{2,8}\s+\d{1,2},\s+\d{4})\b/g,
323
    replace: () => `DOB ${tag("date_of_birth")}`,
324
  },
325
  {
326
    category: "medical_record_id",
327
    pattern:
328
      /\b(?:MRN|medical record(?: number)?|patient id)\s*[:#=]?\s*[A-Za-z0-9-]{6,}\b/gi,
329
    replace: () => `MRN ${tag("medical_record_id")}`,
330
  },
331
  {
332
    category: "phone",
333
    pattern:
334
      /(^|[^\dA-Za-z])(?:\+?1[-.\s]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[-.\s]?[2-9]\d{2}[-.\s]?\d{4}\b/g,
335
    replace: (_m, prefix: string) => `${prefix}${tag("phone")}`,
336
  },
337
  {
338
    category: "ip",
339
    pattern:
340
      /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|100\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g,
341
    replace: () => tag("ip"),
342
  },
343
  {
344
    category: "long_blob",
345
    pattern: /\b[A-Fa-f0-9]{40,}\b/g,
346
    replace: () => tag("long_blob"),
347
  },
348
  {
349
    // A contiguous base64-ish blob. `/` is intentionally EXCLUDED from the class:
350
    // it made slash-separated PROSE (e.g. "candidate/shadow/released/active" or a
351
    // "schema/service/IPC/process/PTY" enum list) match as a fake blob. Real
352
    // base64 secrets are caught by the specific secret rules above and the
353
    // server tripwire; this catch-all only needs contiguous non-slash runs.
354
    category: "long_blob",
355
    pattern: /\b[A-Za-z0-9+]{48,}={0,2}\b/g,
356
    replace: () => tag("long_blob"),
357
  },
358
]
359
360
const collectUsernames = (text: string): Set<string> => {
361
  const names = new Set<string>()
362
  for (const m of text.matchAll(/\/Users\/([A-Za-z0-9._-]+)/g)) {
363
    if (m[1] && m[1] !== "Shared") {
364
      names.add(m[1])
365
    }
366
  }
367
  for (const m of text.matchAll(/\/home\/([A-Za-z0-9._-]+)/g)) {
368
    if (m[1]) {
369
      names.add(m[1])
370
    }
371
  }
372
  for (const m of text.matchAll(/-Users-([A-Za-z0-9._]+?)-/g)) {
373
    if (m[1] && m[1] !== "Shared") {
374
      names.add(m[1])
375
    }
376
  }
377
  return names
378
}
379
380
const mergeReports = (
381
  into: Record<string, number>,
382
  from: RedactionReport,
383
): void => {
384
  for (const [cat, n] of Object.entries(from.counts)) {
385
    into[cat] = (into[cat] ?? 0) + n
386
  }
387
}
388
389
export const redactString = (
390
  input: string,
391
  options: RedactOptions = {},
392
): RedactionResult<string> => {
393
  const counts: Record<string, number> = {}
394
  const bump = (cat: RedactionCategory): void => {
395
    counts[cat] = (counts[cat] ?? 0) + 1
396
  }
397
398
  const { masked, originals } = maskAllowlist(input)
399
  let working = masked
400
401
  for (const rule of RULES) {
402
    rule.pattern.lastIndex = 0
403
    working = working.replace(rule.pattern, (...args: Array<unknown>) => {
404
      const match = args[0] as string
405
      if (match.includes(SENT_OPEN)) {
406
        return match
407
      }
408
      const groups = args.slice(1, -2) as Array<string>
409
      const replaced = rule.replace(match, ...groups)
410
      // Only count a real redaction. A rule whose `replace` returns the match
411
      // unchanged (e.g. the mnemonic wordlist gate rejecting prose) is a no-op
412
      // and must not inflate the report.
413
      if (replaced !== match) {
414
        bump(rule.category)
415
      }
416
      return replaced
417
    })
418
  }
419
420
  for (const name of options.usernames ?? []) {
421
    if (name === "") {
422
      continue
423
    }
424
    const re = new RegExp(escapeRegExp(name), "g")
425
    working = working.replace(re, m => {
426
      if (m.includes(SENT_OPEN)) {
427
        return m
428
      }
429
      bump("username")
430
      return "[REDACTED:home]"
431
    })
432
  }
433
434
  const value = unmaskAllowlist(working, originals)
435
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
436
  return { value, report: { counts, total } }
437
}
438
439
export const redactTraceString = redactString
440
441
type Json = unknown
442
443
export const redactValue = <T extends Json>(
444
  value: T,
445
  options: RedactOptions = {},
446
): RedactionResult<T> => {
447
  const counts: Record<string, number> = {}
448
  const usernames = new Set<string>(options.usernames ?? [])
449
450
  const scan = (v: Json): void => {
451
    if (typeof v === "string") {
452
      for (const name of collectUsernames(v)) {
453
        usernames.add(name)
454
      }
455
      return
456
    }
457
    if (Array.isArray(v)) {
458
      v.forEach(scan)
459
      return
460
    }
461
    if (v !== null && typeof v === "object") {
462
      Object.values(v as Record<string, Json>).forEach(scan)
463
    }
464
  }
465
466
  scan(value)
467
  const opts: RedactOptions = { usernames: Array.from(usernames) }
468
469
  const walk = (v: Json): Json => {
470
    if (typeof v === "string") {
471
      const r = redactString(v, opts)
472
      mergeReports(counts, r.report)
473
      return r.value
474
    }
475
    if (Array.isArray(v)) {
476
      return v.map(walk)
477
    }
478
    if (v !== null && typeof v === "object") {
479
      const out: Record<string, Json> = {}
480
      for (const [k, child] of Object.entries(v as Record<string, Json>)) {
481
        out[k] = walk(child)
482
      }
483
      return out
484
    }
485
    return v
486
  }
487
488
  const redacted = walk(value) as T
489
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
490
  return { value: redacted, report: { counts, total } }
491
}
492
493
export const redactTraceValue = redactValue
494
495
const externalInferencePolicy = (
496
  options: ExternalInferenceRedactionOptions,
497
): ExternalInferenceRedactionResult<unknown>["policy"] => ({
498
  serviceRef: REDACTION_SERVICE_REF,
499
  surface: options.surface,
500
  ...(options.regulatedVertical === undefined
501
    ? {}
502
    : { regulatedVertical: options.regulatedVertical }),
503
  appliedBeforeExternalInference: true,
504
})
505
506
export const redactForExternalInference = <T extends Json>(
507
  value: T,
508
  options: ExternalInferenceRedactionOptions,
509
): ExternalInferenceRedactionResult<T> => {
510
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
511
    options
512
  const redacted = redactValue(value, redactOptions)
513
  return {
514
    ...redacted,
515
    policy: externalInferencePolicy(options),
516
    safeForExternalInference: true,
517
  }
518
}
519
520
export const redactStringForExternalInference = (
521
  text: string,
522
  options: ExternalInferenceRedactionOptions,
523
): ExternalInferenceRedactionResult<string> => {
524
  const { surface: _surface, regulatedVertical: _regulatedVertical, ...redactOptions } =
525
    options
526
  const redacted = redactString(text, redactOptions)
527
  return {
528
    ...redacted,
529
    policy: externalInferencePolicy(options),
530
    safeForExternalInference: true,
531
  }
532
}
533
534
export const makeTraceRedactor = (): TraceRedactorShape => ({
535
  redact: (value, options) => Effect.sync(() => redactValue(value, options)),
536
  redactString: (text, options) => Effect.sync(() => redactString(text, options)),
537
  redactText: (text, options) => Effect.sync(() => redactString(text, options)),
538
  redactForExternalInference: (value, options) =>
539
    Effect.sync(() => redactForExternalInference(value, options)),
540
  redactTextForExternalInference: (text, options) =>
541
    Effect.sync(() => redactStringForExternalInference(text, options)),
542
  redactTrajectory: (trajectory, options) =>
543
    Effect.sync(() => redactValue(trajectory, options)),
544
})
545
546
export class TraceRedactor extends Context.Service<
547
  TraceRedactor,
548
  TraceRedactorShape
549
>()("@openagentsinc/atif/TraceRedactor") {
550
  static readonly Default = Layer.succeed(TraceRedactor, makeTraceRedactor())
551
}
552
553
export const TraceRedactorLive = TraceRedactor.Default
packages/openagents-cli/src/memory/refs.ts added +69

@@ -0,0 +1,69 @@

1
// Vendored from packages/agent-experience-memory/src/contract/refs.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
import { Schema as S } from "effect";
5
6
/**
7
 * Branded references and shared scalars for the owner-local experience memory.
8
 *
9
 * These are portable Effect schemas. Every reference is bounded and
10
 * pattern-checked. An owner scope and a project scope are separate brands so a
11
 * type error, not a convention, stops a cross-owner or cross-project mix.
12
 */
13
14
const memoryRef = <const Brand extends string>(brand: Brand) =>
15
  S.String.check(
16
    S.isMinLength(1),
17
    S.isMaxLength(256),
18
    S.isPattern(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/),
19
  ).pipe(S.brand(brand));
20
21
/** ISO-8601 UTC timestamp, matching the frozen turn timestamp shape. */
22
export const MemoryTimestamp = S.String.check(
23
  S.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/),
24
);
25
export type MemoryTimestamp = typeof MemoryTimestamp.Type;
26
27
/** A 64-character lowercase-hex SHA-256 digest. */
28
export const Sha256Hex = S.String.check(S.isPattern(/^[a-f0-9]{64}$/));
29
export type Sha256Hex = typeof Sha256Hex.Type;
30
31
/** The owner boundary. One owner scope must never read another owner's memory. */
32
export const OwnerScopeId = memoryRef("MemoryOwnerScopeId");
33
export type OwnerScopeId = typeof OwnerScopeId.Type;
34
35
/** The project boundary. Recall stays inside one project without a separate scope grant. */
36
export const ProjectScopeId = memoryRef("MemoryProjectScopeId");
37
export type ProjectScopeId = typeof ProjectScopeId.Type;
38
39
export const RepoRef = memoryRef("MemoryRepoRef");
40
export type RepoRef = typeof RepoRef.Type;
41
42
/** The stable identity of one per-case experience fact. */
43
export const FactRef = memoryRef("MemoryFactRef");
44
export type FactRef = typeof FactRef.Type;
45
46
/** The stable identity of one distilled global pattern (the genuinely new layer). */
47
export const PatternRef = memoryRef("MemoryPatternRef");
48
export type PatternRef = typeof PatternRef.Type;
49
50
/** A frozen eligible bank identity. */
51
export const BankId = memoryRef("MemoryBankId");
52
export type BankId = typeof BankId.Type;
53
54
/** A reference to an existing redacted ATIF trace, never raw trajectory content. */
55
export const TraceRef = memoryRef("MemoryTraceRef");
56
export type TraceRef = typeof TraceRef.Type;
57
58
/** Consent for reuse of a record. It defaults to withheld, matching the trace store. */
59
export const MemoryConsent = S.Literals(["granted", "withheld"]);
60
export type MemoryConsent = typeof MemoryConsent.Type;
61
62
/** Trusted constructors for scripts, tests, and derivation paths. */
63
export const ownerScopeId = S.decodeUnknownSync(OwnerScopeId);
64
export const projectScopeId = S.decodeUnknownSync(ProjectScopeId);
65
export const repoRef = S.decodeUnknownSync(RepoRef);
66
export const factRef = S.decodeUnknownSync(FactRef);
67
export const patternRef = S.decodeUnknownSync(PatternRef);
68
export const bankId = S.decodeUnknownSync(BankId);
69
export const traceRef = S.decodeUnknownSync(TraceRef);
packages/openagents-cli/src/memory/sha256.ts added +108

@@ -0,0 +1,108 @@

1
// Vendored from packages/agent-experience-memory/src/internal/sha256.ts by scripts/vendor-memory.mjs — do not edit here.
2
// The drift guard (test/vendored-memory-drift.test.ts) fails when this copy
3
// no longer matches the canonical source.
4
/**
5
 * A dependency-free FIPS 180-4 SHA-256 over bytes.
6
 *
7
 * The DSE package is offline and portable: it imports no Node API, no provider
8
 * SDK, and no platform host. A compiled artifact must be content-addressed by a
9
 * digest over ALL its bytes (the audit correction that a compiled ID must cover
10
 * the complete artifact, not only a parameter hash). This module is the single
11
 * hasher the whole package uses for candidate digests, dataset identity,
12
 * evaluation digests, and offline artifact verification, so compile and verify
13
 * always agree by construction.
14
 *
15
 * Correctness is pinned by known test vectors in `sha256.test.ts`.
16
 */
17
18
const K = new Uint32Array([
19
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
20
  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
21
  0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
22
  0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
23
  0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
24
  0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
25
  0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
26
  0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
27
]);
28
29
const rotr = (value: number, bits: number): number => (value >>> bits) | (value << (32 - bits));
30
31
const toHex = (words: Uint32Array): string => {
32
  let hex = "";
33
  for (const word of words) hex += (word >>> 0).toString(16).padStart(8, "0");
34
  return hex;
35
};
36
37
/** Return the lowercase-hex SHA-256 digest of the input bytes. */
38
export const sha256 = (input: Uint8Array): string => {
39
  const bitLength = input.length * 8;
40
  // Pad: append 0x80, then zeros, then the 64-bit big-endian bit length.
41
  const paddedLength = (((input.length + 8) >> 6) + 1) << 6;
42
  const bytes = new Uint8Array(paddedLength);
43
  bytes.set(input);
44
  bytes[input.length] = 0x80;
45
  // Only the low 32 bits of the length are needed for the payloads we hash.
46
  const view = new DataView(bytes.buffer);
47
  view.setUint32(paddedLength - 4, bitLength >>> 0, false);
48
  view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
49
50
  const h = new Uint32Array([
51
    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
52
  ]);
53
  const w = new Uint32Array(64);
54
55
  // Fixed-size Uint32Array accesses at in-range indices are always defined. The
56
  // non-null assertions below are pure type annotations (no runtime effect) so
57
  // the file also compiles for a `noUncheckedIndexedAccess` consumer.
58
  for (let offset = 0; offset < paddedLength; offset += 64) {
59
    for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4, false);
60
    for (let i = 16; i < 64; i += 1) {
61
      const w15 = w[i - 15]!;
62
      const w2 = w[i - 2]!;
63
      const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);
64
      const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);
65
      w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) | 0;
66
    }
67
68
    let a = h[0]!;
69
    let b = h[1]!;
70
    let c = h[2]!;
71
    let d = h[3]!;
72
    let e = h[4]!;
73
    let f = h[5]!;
74
    let g = h[6]!;
75
    let hh = h[7]!;
76
77
    for (let i = 0; i < 64; i += 1) {
78
      const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
79
      const ch = (e & f) ^ (~e & g);
80
      const t1 = (hh + s1 + ch + K[i]! + w[i]!) | 0;
81
      const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
82
      const maj = (a & b) ^ (a & c) ^ (b & c);
83
      const t2 = (s0 + maj) | 0;
84
      hh = g;
85
      g = f;
86
      f = e;
87
      e = (d + t1) | 0;
88
      d = c;
89
      c = b;
90
      b = a;
91
      a = (t1 + t2) | 0;
92
    }
93
94
    h[0] = (h[0]! + a) | 0;
95
    h[1] = (h[1]! + b) | 0;
96
    h[2] = (h[2]! + c) | 0;
97
    h[3] = (h[3]! + d) | 0;
98
    h[4] = (h[4]! + e) | 0;
99
    h[5] = (h[5]! + f) | 0;
100
    h[6] = (h[6]! + g) | 0;
101
    h[7] = (h[7]! + hh) | 0;
102
  }
103
104
  return toHex(h);
105
};
106
107
/** Return the lowercase-hex SHA-256 digest of the UTF-8 encoding of the text. */
108
export const sha256Hex = (text: string): string => sha256(new TextEncoder().encode(text));
packages/openagents-cli/src/memory/subagent-memory.ts added +261

@@ -0,0 +1,261 @@

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

@@ -0,0 +1,148 @@

1
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { afterEach, describe, expect, it } from "vitest";
5
6
import { CoderMemory } from "../src/coder-memory.js";
7
import { DelegateFleet, type DelegateEvent, type DelegateHarness } from "../src/coder-delegate.js";
8
import { CoderTaskRegistry } from "../src/coder-tasks.js";
9
10
const dirs: Array<string> = [];
11
const freshDir = (): string => {
12
  const dir = mkdtempSync(join(tmpdir(), "coder-memory-test-"));
13
  dirs.push(dir);
14
  return dir;
15
};
16
17
afterEach(() => {
18
  for (const dir of dirs.splice(0)) {
19
    rmSync(dir, { recursive: true, force: true });
20
  }
21
});
22
23
const memoryAt = (dir: string, nowMs = 1_756_000_000_000): CoderMemory =>
24
  new CoderMemory({ directory: dir, projectScope: "project:test", now: () => nowMs });
25
26
describe("CoderMemory ledger", () => {
27
  it("records an engram and reads it back verified", () => {
28
    const dir = freshDir();
29
    const memory = memoryAt(dir);
30
    const event = memory.record("note/one", "prefer ranged reads over full dumps", "note-1");
31
    expect(event).toBeDefined();
32
    const bodies = memoryAt(dir).bodies();
33
    expect(bodies).toHaveLength(1);
34
    expect(bodies[0]?.value).toBe("prefer ranged reads over full dumps");
35
  });
36
37
  it("refuses to store credential-shaped material", () => {
38
    const dir = freshDir();
39
    const memory = memoryAt(dir);
40
    const event = memory.record("note/bad", "the token is oa_pat_abc123def456ghi789jkl012", "n");
41
    expect(event).toBeUndefined();
42
    expect(memoryAt(dir).bodies()).toHaveLength(0);
43
  });
44
45
  it("a correction supersedes without rewriting, and a tombstone hides the slug", () => {
46
    const dir = freshDir();
47
    const memory = memoryAt(dir);
48
    memory.record("note/one", "first version", "note-1");
49
    const superseding = memory.correct("note/one", "second version");
50
    expect(superseding).toBeDefined();
51
    expect(memory.bodies().map((body) => body.value)).toEqual(["second version"]);
52
    // Both events remain in the ledger; nothing was rewritten.
53
    const lines = readFileSync(join(dir, "engrams.jsonl"), "utf8").trim().split("\n");
54
    expect(lines).toHaveLength(2);
55
    memory.correct("note/one", null);
56
    expect(memory.bodies()).toHaveLength(0);
57
  });
58
59
  it("a hand-edited ledger line fails verification and is dropped", () => {
60
    const dir = freshDir();
61
    const memory = memoryAt(dir);
62
    memory.record("note/one", "authentic", "note-1");
63
    const path = join(dir, "engrams.jsonl");
64
    const tampered = readFileSync(path, "utf8").replace("authentic", "forged");
65
    rmSync(path);
66
    writeFileSync(path, tampered);
67
    expect(memoryAt(dir).bodies()).toHaveLength(0);
68
  });
69
});
70
71
describe("harvest and inherit", () => {
72
  it("a harvested child answer comes back as an advisory block for the next child", () => {
73
    const dir = freshDir();
74
    const memory = memoryAt(dir);
75
    memory.harvest("task-1", "The build needs pnpm, not npm: npm leaves catalog: protocols.");
76
    const block = memory.inherit("set up the build");
77
    expect(block).toContain("[inherited parent memory — advisory only]");
78
    expect(block).toContain("pnpm");
79
    // Opaque refs only: the child never sees ledger entry ids.
80
    expect(block).not.toContain("harvest:");
81
  });
82
83
  it("an unsafe child answer is not remembered", () => {
84
    const dir = freshDir();
85
    const memory = memoryAt(dir);
86
    memory.harvest("task-1", "use Bearer abc.def.ghi for the calls");
87
    expect(memory.inherit("anything")).toBe("");
88
  });
89
90
  it("harvest never throws, even with an unwritable directory", () => {
91
    const memory = new CoderMemory({ directory: "/dev/null/nope", now: () => 0 });
92
    expect(() => memory.harvest("t", "finding")).not.toThrow();
93
    expect(memory.inherit("task")).toBe("");
94
  });
95
});
96
97
describe("fleet wiring", () => {
98
  const oneShotHarness = (answer: string): DelegateHarness => ({
99
    agent: "test",
100
    model: "test-model",
101
    // eslint-disable-next-line @typescript-eslint/require-await
102
    async *run(): AsyncIterable<DelegateEvent> {
103
      yield { type: "text", value: answer };
104
    },
105
  });
106
107
  it("injects inherited memory into the child prompt and harvests the answer", async () => {
108
    const dir = freshDir();
109
    const memory = memoryAt(dir);
110
    memory.harvest("earlier", "Always run mix format before committing Elixir.");
111
112
    let seenPrompt = "";
113
    const harness: DelegateHarness = {
114
      agent: "test",
115
      model: "test-model",
116
      // eslint-disable-next-line @typescript-eslint/require-await
117
      async *run(input): AsyncIterable<DelegateEvent> {
118
        seenPrompt = input.prompt;
119
        yield { type: "text", value: "Learned: the forge remote is named openagents." };
120
      },
121
    };
122
    const fleet = new DelegateFleet(new CoderTaskRegistry(), harness, {
123
      maxConcurrent: 1,
124
      cwd: dir,
125
      transcriptDirectory: join(dir, "transcripts"),
126
      memory,
127
    });
128
    const outcome = await fleet.submit({ prompt: "do the task", description: "test" });
129
    expect(outcome.status).toBe("completed");
130
    expect(seenPrompt).toContain("do the task");
131
    expect(seenPrompt).toContain("[inherited parent memory — advisory only]");
132
    expect(seenPrompt).toContain("mix format");
133
    // The child's answer was harvested for the next generation.
134
    const nextBlock = memory.inherit("push the repo");
135
    expect(nextBlock).toContain("openagents");
136
  });
137
138
  it("a fleet without memory behaves exactly as before", async () => {
139
    const dir = freshDir();
140
    const fleet = new DelegateFleet(new CoderTaskRegistry(), oneShotHarness("done"), {
141
      maxConcurrent: 1,
142
      cwd: dir,
143
      transcriptDirectory: join(dir, "transcripts"),
144
    });
145
    const outcome = await fleet.submit({ prompt: "plain task", description: "test" });
146
    expect(outcome.status).toBe("completed");
147
  });
148
});
packages/openagents-cli/test/vendored-memory-drift.test.ts added +25

@@ -0,0 +1,25 @@

1
import { readFileSync } from "node:fs";
2
import { join } from "node:path";
3
import { describe, expect, it } from "vitest";
4
5
// The vendor script is the single definition of the transform; the guard
6
// re-runs it and compares, so the vendored tree cannot drift from the
7
// canonical packages silently.
8
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
9
const { VENDORED, renderVendored } = (await import("../scripts/vendor-memory.mjs")) as unknown as {
10
  VENDORED: ReadonlyArray<readonly [string, string, ReadonlyArray<readonly [string, string]>]>;
11
  renderVendored: (source: string, rewrites: ReadonlyArray<readonly [string, string]>) => string;
12
};
13
14
const memoryDir = join(__dirname, "..", "src", "memory");
15
16
describe("the vendored memory tree", () => {
17
  it("matches the canonical packages exactly, import rewrites aside", () => {
18
    for (const [source, name, rewrites] of VENDORED) {
19
      const vendored = readFileSync(join(memoryDir, name), "utf8");
20
      expect(vendored, `${name} drifted from packages/${source}`).toBe(
21
        renderVendored(source, rewrites),
22
      );
23
    }
24
  });
25
});
scripts/uncalled-production-symbol-baseline.json modified -29

@@ -1012,7 +1012,6 @@

1012 1012
    "packages/agent-experience-memory/src/owner-profile.ts#disabledOwnerProfileStoreLayer",
1013 1013
    "packages/agent-experience-memory/src/owner-profile.ts#inMemoryOwnerProfileStoreLayer",
1014 1014
    "packages/agent-experience-memory/src/owner-profile.ts#ownerProfileScopeForNpub",
1015
    "packages/agent-experience-memory/src/ranking.ts#recallOrderBySalience",
1016 1015
    "packages/agent-experience-memory/src/store.ts#disabledMemoryStoreLayer",
1017 1016
    "packages/agent-experience-memory/src/store.ts#inMemoryMemoryStoreLayer",
1018 1017
    "packages/agent-readiness/src/index.ts#agentReadinessTaskForDomain",

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

1773 1772
    "scripts/github-issue-triage.ts#issueHasAnyLabel"
1774 1773
  ],
1775 1774
  "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
    },
1792 1775
    {
1793 1776
      "ref": "packages/agent-experience-memory/src/graph-memory-store.ts#GraphMemoryStoreInterface.applyDeletePlan",
1794 1777
      "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."

@@ -1797,18 +1780,6 @@

1797 1780
      "ref": "packages/agent-experience-memory/src/graph-memory-store.ts#GraphMemoryStoreInterface.exportArchive",
1798 1781
      "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."
1799 1782
    },
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
    },
1812 1783
    {
1813 1784
      "ref": "packages/agent-surface/src/index.ts#projectSafeMessageChain",
1814 1785
      "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."

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