@rohankumar4179/engram-sdk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/anchor.d.ts +114 -0
- package/dist/anchor.js +243 -0
- package/dist/anchor.js.map +1 -0
- package/dist/audit.d.ts +53 -0
- package/dist/audit.js +122 -0
- package/dist/audit.js.map +1 -0
- package/dist/consolidator.d.ts +62 -0
- package/dist/consolidator.js +141 -0
- package/dist/consolidator.js.map +1 -0
- package/dist/crypto.d.ts +26 -0
- package/dist/crypto.js +72 -0
- package/dist/crypto.js.map +1 -0
- package/dist/engram.d.ts +266 -0
- package/dist/engram.js +681 -0
- package/dist/engram.js.map +1 -0
- package/dist/env.d.ts +28 -0
- package/dist/env.js +81 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +19 -0
- package/dist/inspect.js +61 -0
- package/dist/inspect.js.map +1 -0
- package/dist/link.d.ts +40 -0
- package/dist/link.js +57 -0
- package/dist/link.js.map +1 -0
- package/dist/llm.d.ts +60 -0
- package/dist/llm.js +188 -0
- package/dist/llm.js.map +1 -0
- package/dist/memwal-account.d.ts +39 -0
- package/dist/memwal-account.js +75 -0
- package/dist/memwal-account.js.map +1 -0
- package/dist/merkle.d.ts +14 -0
- package/dist/merkle.js +69 -0
- package/dist/merkle.js.map +1 -0
- package/dist/mind.d.ts +150 -0
- package/dist/mind.js +193 -0
- package/dist/mind.js.map +1 -0
- package/dist/proof.d.ts +21 -0
- package/dist/proof.js +46 -0
- package/dist/proof.js.map +1 -0
- package/dist/remote.d.ts +112 -0
- package/dist/remote.js +232 -0
- package/dist/remote.js.map +1 -0
- package/dist/store.d.ts +28 -0
- package/dist/store.js +72 -0
- package/dist/store.js.map +1 -0
- package/dist/tatum.d.ts +83 -0
- package/dist/tatum.js +148 -0
- package/dist/tatum.js.map +1 -0
- package/dist/types.d.ts +122 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vault.d.ts +31 -0
- package/dist/vault.js +45 -0
- package/dist/vault.js.map +1 -0
- package/package.json +53 -3
package/dist/engram.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { canonical, decrypt, encrypt, generateKeyPair, hexToBytes, newDataKey, publicKeyOf, randomId, sha256Hex, sign } from "./crypto.js";
|
|
3
|
+
import { LocalStore } from "./store.js";
|
|
4
|
+
import { KeyVault } from "./vault.js";
|
|
5
|
+
import { proposeOps } from "./consolidator.js";
|
|
6
|
+
import { buildCommitTree, commitId, proofFor, verifyBeliefProof, verifyManifestSignature } from "./proof.js";
|
|
7
|
+
import { verifySignature } from "./crypto.js";
|
|
8
|
+
export class NothingToCommitError extends Error {
|
|
9
|
+
constructor(first = false) {
|
|
10
|
+
super(first ? "nothing to commit — no beliefs yet (observe episodes, then run sleep)" : "nothing to commit — beliefs are unchanged since the last commit");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** "b_rent@2" */
|
|
14
|
+
export const beliefKey = (b) => `${b.id}@${b.version}`;
|
|
15
|
+
export class Engram {
|
|
16
|
+
store;
|
|
17
|
+
vault;
|
|
18
|
+
config;
|
|
19
|
+
remote;
|
|
20
|
+
anchorer;
|
|
21
|
+
secretKey;
|
|
22
|
+
/** True when opened without keys — nothing can be signed or decrypted. */
|
|
23
|
+
readOnly;
|
|
24
|
+
constructor(store, remote, anchorer, readOnly = false) {
|
|
25
|
+
this.store = store;
|
|
26
|
+
this.remote = remote;
|
|
27
|
+
this.anchorer = anchorer;
|
|
28
|
+
const config = store.file("config.json").read();
|
|
29
|
+
if (!config)
|
|
30
|
+
throw new Error(`No Engram repo at ${store.root}. Run \`engram init\` first.`);
|
|
31
|
+
this.config = config;
|
|
32
|
+
const sk = store.readText("agent.key");
|
|
33
|
+
const mk = store.readText("master.key");
|
|
34
|
+
this.readOnly = readOnly;
|
|
35
|
+
if (readOnly) {
|
|
36
|
+
this.secretKey = "";
|
|
37
|
+
// A vault with a throwaway master key and no entries: every read() returns null.
|
|
38
|
+
this.vault = new KeyVault(store.file("vault.json"), newDataKey());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!sk || !mk)
|
|
42
|
+
throw new Error("Missing agent.key or master.key in .engram/");
|
|
43
|
+
if (publicKeyOf(sk) !== config.agentPubKey)
|
|
44
|
+
throw new Error("agent.key does not match config.json");
|
|
45
|
+
this.secretKey = sk;
|
|
46
|
+
this.vault = new KeyVault(store.file("vault.json"), hexToBytes(mk));
|
|
47
|
+
}
|
|
48
|
+
/** Creates `.engram/` with a fresh agent signing key and master key. */
|
|
49
|
+
static init(agent, opts) {
|
|
50
|
+
if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(agent)) {
|
|
51
|
+
throw new Error("agent name: lowercase letters, digits and dashes only");
|
|
52
|
+
}
|
|
53
|
+
const store = new LocalStore(join(resolve(opts.dir ?? "."), ".engram"));
|
|
54
|
+
if (store.exists())
|
|
55
|
+
throw new Error(`Engram repo already exists at ${store.root}`);
|
|
56
|
+
const kp = generateKeyPair();
|
|
57
|
+
store.writeText("agent.key", kp.secretKey);
|
|
58
|
+
store.writeText("master.key", newDataKey().toString("hex"));
|
|
59
|
+
const ns = (k) => `engram-${agent}-${k}`;
|
|
60
|
+
store.file("config.json").write({
|
|
61
|
+
v: 1,
|
|
62
|
+
agent,
|
|
63
|
+
agentPubKey: kp.publicKey,
|
|
64
|
+
createdAt: Date.now(),
|
|
65
|
+
namespaces: { episodes: ns("episodes"), beliefs: ns("beliefs"), commits: ns("commits"), receipts: ns("receipts") },
|
|
66
|
+
});
|
|
67
|
+
return new Engram(store, opts.remote, opts.anchorer);
|
|
68
|
+
}
|
|
69
|
+
static open(opts) {
|
|
70
|
+
return new Engram(new LocalStore(join(resolve(opts.dir ?? "."), ".engram")), opts.remote, opts.anchorer, opts.readOnly);
|
|
71
|
+
}
|
|
72
|
+
get agent() {
|
|
73
|
+
return this.config.agent;
|
|
74
|
+
}
|
|
75
|
+
get publicKey() {
|
|
76
|
+
return this.config.agentPubKey;
|
|
77
|
+
}
|
|
78
|
+
/** Signs with the agent key (commits and receipts). */
|
|
79
|
+
signRaw(message) {
|
|
80
|
+
if (this.readOnly)
|
|
81
|
+
throw new Error("this repo is read-only (opened without the agent's key)");
|
|
82
|
+
return sign(message, this.secretKey);
|
|
83
|
+
}
|
|
84
|
+
// ---- Episodes ------------------------------------------------------------
|
|
85
|
+
/** Records a raw event, encrypted with its own key. Stored locally; `push()` uploads it. */
|
|
86
|
+
observe(input) {
|
|
87
|
+
const id = randomId("ep");
|
|
88
|
+
const ts = input.ts ?? Date.now();
|
|
89
|
+
const content = { source: input.source, ts, data: input.data };
|
|
90
|
+
const plaintext = canonical(content);
|
|
91
|
+
const { keyId, key } = this.vault.create();
|
|
92
|
+
const sealed = encrypt(Buffer.from(plaintext, "utf8"), key, id);
|
|
93
|
+
const envelope = { engram: "episode", v: 1, id, keyId, ...sealed };
|
|
94
|
+
const episode = {
|
|
95
|
+
id,
|
|
96
|
+
ts,
|
|
97
|
+
source: input.source,
|
|
98
|
+
summary: input.summary,
|
|
99
|
+
contentHash: sha256Hex(plaintext),
|
|
100
|
+
keyId,
|
|
101
|
+
envelope,
|
|
102
|
+
};
|
|
103
|
+
this.store.put("episodes", id, episode);
|
|
104
|
+
return episode;
|
|
105
|
+
}
|
|
106
|
+
episodes() {
|
|
107
|
+
return this.store.list("episodes").sort((a, b) => a.ts - b.ts || a.id.localeCompare(b.id));
|
|
108
|
+
}
|
|
109
|
+
episode(id) {
|
|
110
|
+
return this.store.get("episodes", id);
|
|
111
|
+
}
|
|
112
|
+
/** Decrypts an episode. Returns null if it was forgotten. Throws if it was tampered with. */
|
|
113
|
+
read(id) {
|
|
114
|
+
const ep = this.episode(id);
|
|
115
|
+
if (!ep)
|
|
116
|
+
throw new Error(`unknown episode ${id}`);
|
|
117
|
+
const key = this.vault.get(ep.keyId);
|
|
118
|
+
if (!key)
|
|
119
|
+
return null;
|
|
120
|
+
const plaintext = decrypt(ep.envelope, key, ep.id).toString("utf8");
|
|
121
|
+
if (sha256Hex(plaintext) !== ep.contentHash)
|
|
122
|
+
throw new Error(`episode ${id} content hash mismatch`);
|
|
123
|
+
return JSON.parse(plaintext);
|
|
124
|
+
}
|
|
125
|
+
// ---- Beliefs ---------------------------------------------------------------
|
|
126
|
+
/** Every version of every belief, oldest first. */
|
|
127
|
+
beliefVersions() {
|
|
128
|
+
return this.store.list("beliefs").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id) || a.version - b.version);
|
|
129
|
+
}
|
|
130
|
+
/** Latest version of each belief, including retracted ones. */
|
|
131
|
+
latestBeliefs() {
|
|
132
|
+
const latest = new Map();
|
|
133
|
+
for (const b of this.beliefVersions()) {
|
|
134
|
+
const cur = latest.get(b.id);
|
|
135
|
+
if (!cur || b.version > cur.version)
|
|
136
|
+
latest.set(b.id, b);
|
|
137
|
+
}
|
|
138
|
+
return [...latest.values()];
|
|
139
|
+
}
|
|
140
|
+
/** The beliefs that currently guide the agent. */
|
|
141
|
+
beliefs() {
|
|
142
|
+
return this.latestBeliefs().filter((b) => b.status === "active");
|
|
143
|
+
}
|
|
144
|
+
/** A belief by "b_x" (latest) or "b_x@2" (exact version). */
|
|
145
|
+
belief(ref) {
|
|
146
|
+
const [id, v] = ref.split("@");
|
|
147
|
+
if (v)
|
|
148
|
+
return this.store.get("beliefs", ref);
|
|
149
|
+
return this.latestBeliefs().find((b) => b.id === id) ?? null;
|
|
150
|
+
}
|
|
151
|
+
/** All versions of one belief, oldest first. */
|
|
152
|
+
history(id) {
|
|
153
|
+
return this.beliefVersions().filter((b) => b.id === id).sort((a, b) => a.version - b.version);
|
|
154
|
+
}
|
|
155
|
+
/** Episodes that `sleep()` has not processed yet (forgotten ones are skipped). */
|
|
156
|
+
pendingEpisodes() {
|
|
157
|
+
return this.episodes().filter((e) => !e.consolidatedAt && !e.forgottenAt);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Consolidation: new episodes + current beliefs → belief changes, via the LLM.
|
|
161
|
+
* Every change is a new immutable belief version with evidence and a reason.
|
|
162
|
+
*/
|
|
163
|
+
async sleep(opts) {
|
|
164
|
+
const report = { episodes: 0, created: [], revised: [], retracted: [], rejected: [] };
|
|
165
|
+
const pending = this.pendingEpisodes();
|
|
166
|
+
const size = opts.batchSize ?? 40;
|
|
167
|
+
for (let i = 0; i < pending.length; i += size) {
|
|
168
|
+
const batch = pending.slice(i, i + size);
|
|
169
|
+
const episodes = batch.flatMap((e) => {
|
|
170
|
+
const content = this.read(e.id);
|
|
171
|
+
return content ? [{ id: e.id, content, summary: e.summary }] : [];
|
|
172
|
+
});
|
|
173
|
+
const { ops, rejected } = await proposeOps(opts.llm, { episodes, beliefs: this.beliefs(), context: opts.context });
|
|
174
|
+
report.rejected.push(...rejected);
|
|
175
|
+
for (const op of ops) {
|
|
176
|
+
const b = this.applyOp(op);
|
|
177
|
+
if (op.op === "create")
|
|
178
|
+
report.created.push(b);
|
|
179
|
+
else if (op.op === "revise")
|
|
180
|
+
report.revised.push(b);
|
|
181
|
+
else
|
|
182
|
+
report.retracted.push(b);
|
|
183
|
+
}
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
for (const e of batch)
|
|
186
|
+
this.store.put("episodes", e.id, { ...e, consolidatedAt: now });
|
|
187
|
+
report.episodes += batch.length;
|
|
188
|
+
}
|
|
189
|
+
return report;
|
|
190
|
+
}
|
|
191
|
+
/** Applies one belief change. Exposed so users (and tests) can edit beliefs by hand. */
|
|
192
|
+
applyOp(op) {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
if (op.op === "create") {
|
|
195
|
+
const taken = new Set(this.latestBeliefs().map((b) => b.id));
|
|
196
|
+
let id = `b_${op.key}`;
|
|
197
|
+
for (let n = 2; taken.has(id); n++)
|
|
198
|
+
id = `b_${op.key}_${n}`;
|
|
199
|
+
const b = { id, version: 1, statement: op.statement, confidence: op.confidence, evidence: [...op.evidence].sort(), tags: op.tags, status: "active", createdAt: now, reason: op.reason };
|
|
200
|
+
this.store.put("beliefs", beliefKey(b), b);
|
|
201
|
+
return b;
|
|
202
|
+
}
|
|
203
|
+
const prev = this.belief(op.belief);
|
|
204
|
+
if (!prev)
|
|
205
|
+
throw new Error(`unknown belief ${op.belief}`);
|
|
206
|
+
const next = op.op === "revise"
|
|
207
|
+
? { id: prev.id, version: prev.version + 1, statement: op.statement, confidence: op.confidence, evidence: [...op.evidence].sort(), tags: op.tags?.length ? op.tags : prev.tags, supersedes: beliefKey(prev), status: "active", createdAt: now, reason: op.reason }
|
|
208
|
+
: { ...prev, version: prev.version + 1, supersedes: beliefKey(prev), status: "retracted", createdAt: now, reason: op.reason, blobId: undefined };
|
|
209
|
+
this.store.put("beliefs", beliefKey(next), next);
|
|
210
|
+
return next;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Beliefs relevant to a task. Uses Walrus Memory semantic recall over pushed
|
|
214
|
+
* beliefs, then falls back to keyword matching for anything not pushed yet.
|
|
215
|
+
*/
|
|
216
|
+
async relevant(query, limit = 5) {
|
|
217
|
+
const active = new Map(this.beliefs().map((b) => [beliefKey(b), b]));
|
|
218
|
+
const out = [];
|
|
219
|
+
try {
|
|
220
|
+
const hits = await this.remote.search(this.config.namespaces.beliefs, query, limit * 3);
|
|
221
|
+
for (const h of hits) {
|
|
222
|
+
const key = parseBeliefText(h.text);
|
|
223
|
+
const b = key ? active.get(key) : undefined;
|
|
224
|
+
if (b && !out.includes(b))
|
|
225
|
+
out.push(b);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// remote unavailable — keyword fallback below
|
|
230
|
+
}
|
|
231
|
+
if (out.length < limit) {
|
|
232
|
+
const words = query.toLowerCase().split(/\W+/).filter((w) => w.length > 2);
|
|
233
|
+
const scored = [...active.values()]
|
|
234
|
+
.filter((b) => !out.includes(b))
|
|
235
|
+
.map((b) => ({ b, s: words.filter((w) => `${b.statement} ${b.tags.join(" ")}`.toLowerCase().includes(w)).length }))
|
|
236
|
+
.filter((x) => x.s > 0)
|
|
237
|
+
.sort((a, b) => b.s - a.s || b.b.confidence - a.b.confidence);
|
|
238
|
+
out.push(...scored.map((x) => x.b));
|
|
239
|
+
}
|
|
240
|
+
return out.slice(0, limit);
|
|
241
|
+
}
|
|
242
|
+
// ---- Commits ---------------------------------------------------------------
|
|
243
|
+
/** Current commit ID, or null before the first commit. */
|
|
244
|
+
head() {
|
|
245
|
+
return this.store.readText("HEAD") || null;
|
|
246
|
+
}
|
|
247
|
+
getCommit(id) {
|
|
248
|
+
return this.store.get("commits", id);
|
|
249
|
+
}
|
|
250
|
+
/** IDs of forgotten episodes, sorted. */
|
|
251
|
+
tombstones() {
|
|
252
|
+
return this.episodes().filter((e) => e.forgottenAt).map((e) => e.id).sort();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Snapshot of the current beliefs: push pending data, build the Merkle root,
|
|
256
|
+
* sign the manifest, store it on Walrus Memory and look up its Sui anchor.
|
|
257
|
+
*/
|
|
258
|
+
async commit(message, opts = {}) {
|
|
259
|
+
const parent = this.head();
|
|
260
|
+
const beliefs = this.beliefs().sort((a, b) => beliefKey(a).localeCompare(beliefKey(b)));
|
|
261
|
+
const tombstones = this.tombstones();
|
|
262
|
+
const { tree } = buildCommitTree(beliefs, tombstones);
|
|
263
|
+
const parentCommit = parent ? this.getCommit(parent) : null;
|
|
264
|
+
if (!opts.allowEmpty && parentCommit && parentCommit.manifest.merkleRoot === tree.root)
|
|
265
|
+
throw new NothingToCommitError();
|
|
266
|
+
if (!opts.allowEmpty && !parentCommit && beliefs.length === 0 && tombstones.length === 0)
|
|
267
|
+
throw new NothingToCommitError(true);
|
|
268
|
+
await this.push();
|
|
269
|
+
const pushedBeliefs = beliefs.map((b) => this.belief(beliefKey(b)));
|
|
270
|
+
const episodes = {};
|
|
271
|
+
for (const b of pushedBeliefs) {
|
|
272
|
+
for (const id of b.evidence) {
|
|
273
|
+
const ep = this.episode(id);
|
|
274
|
+
if (ep?.blobId)
|
|
275
|
+
episodes[id] = ep.blobId;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const manifest = {
|
|
279
|
+
engram: "commit",
|
|
280
|
+
v: 1,
|
|
281
|
+
agent: this.agent,
|
|
282
|
+
parent,
|
|
283
|
+
ts: Date.now(),
|
|
284
|
+
message,
|
|
285
|
+
merkleRoot: tree.root,
|
|
286
|
+
beliefs: pushedBeliefs.map(beliefKey),
|
|
287
|
+
tombstones,
|
|
288
|
+
episodes,
|
|
289
|
+
beliefBlobs: Object.fromEntries(pushedBeliefs.map((b) => [beliefKey(b), b.blobId ?? ""])),
|
|
290
|
+
agentPubKey: this.publicKey,
|
|
291
|
+
};
|
|
292
|
+
const id = commitId(manifest);
|
|
293
|
+
const signature = this.signRaw(canonical(manifest));
|
|
294
|
+
const [manifestBlobId] = await this.remote.putMany([{ namespace: this.config.namespaces.commits, text: commitText(id, manifest, signature) }]);
|
|
295
|
+
const commit = { id, manifest, signature, manifestBlobId };
|
|
296
|
+
this.store.put("commits", id, commit);
|
|
297
|
+
this.store.writeText("HEAD", id);
|
|
298
|
+
return this.anchor(id);
|
|
299
|
+
}
|
|
300
|
+
/** Looks up (or retries looking up) the Sui anchor of a commit. */
|
|
301
|
+
async anchor(id) {
|
|
302
|
+
const c = this.getCommit(id);
|
|
303
|
+
if (!c)
|
|
304
|
+
throw new Error(`unknown commit ${id}`);
|
|
305
|
+
if (c.anchor || !this.anchorer || !c.manifestBlobId)
|
|
306
|
+
return c;
|
|
307
|
+
const anchor = this.anchorer.publish ? await this.anchorer.publish(c) : await this.anchorer.find(c.manifestBlobId);
|
|
308
|
+
if (!anchor)
|
|
309
|
+
return c;
|
|
310
|
+
const next = { ...c, anchor };
|
|
311
|
+
this.store.put("commits", id, next);
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
/** Commits from HEAD back to the first one. */
|
|
315
|
+
log(from) {
|
|
316
|
+
const out = [];
|
|
317
|
+
let id = from ? this.resolve(from) : this.head();
|
|
318
|
+
while (id) {
|
|
319
|
+
const c = this.getCommit(id);
|
|
320
|
+
if (!c)
|
|
321
|
+
throw new Error(`broken history: missing commit ${id}`);
|
|
322
|
+
out.push(c);
|
|
323
|
+
id = c.manifest.parent;
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Resolves "HEAD", "HEAD~2", a full or short (≥4 chars) commit ID, or a
|
|
329
|
+
* date ("2026-10-25") → the last commit made at or before it.
|
|
330
|
+
*/
|
|
331
|
+
resolve(ref) {
|
|
332
|
+
const all = this.log();
|
|
333
|
+
if (all.length === 0)
|
|
334
|
+
throw new Error("no commits yet");
|
|
335
|
+
const m = /^HEAD(?:~(\d+))?$/.exec(ref);
|
|
336
|
+
if (m) {
|
|
337
|
+
const c = all[Number(m[1] ?? 0)];
|
|
338
|
+
if (!c)
|
|
339
|
+
throw new Error(`${ref} is beyond the first commit`);
|
|
340
|
+
return c.id;
|
|
341
|
+
}
|
|
342
|
+
if (/^[0-9a-f]{4,64}$/.test(ref)) {
|
|
343
|
+
const hits = all.filter((c) => c.id.startsWith(ref));
|
|
344
|
+
if (hits.length === 1)
|
|
345
|
+
return hits[0].id;
|
|
346
|
+
if (hits.length > 1)
|
|
347
|
+
throw new Error(`ambiguous commit ${ref}`);
|
|
348
|
+
}
|
|
349
|
+
const t = Date.parse(ref.length === 10 ? `${ref}T23:59:59.999Z` : ref);
|
|
350
|
+
if (!Number.isNaN(t)) {
|
|
351
|
+
const c = all.find((x) => x.manifest.ts <= t);
|
|
352
|
+
if (!c)
|
|
353
|
+
throw new Error(`no commit at or before ${ref}`);
|
|
354
|
+
return c.id;
|
|
355
|
+
}
|
|
356
|
+
throw new Error(`unknown commit ${ref}`);
|
|
357
|
+
}
|
|
358
|
+
/** The beliefs the agent had at a commit — time travel. */
|
|
359
|
+
beliefsAt(ref) {
|
|
360
|
+
const c = this.getCommit(this.resolve(ref));
|
|
361
|
+
return c.manifest.beliefs.map((k) => {
|
|
362
|
+
const b = this.belief(k);
|
|
363
|
+
if (!b)
|
|
364
|
+
throw new Error(`commit ${c.id.slice(0, 8)} references missing belief ${k}`);
|
|
365
|
+
return b;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** What changed between two commits (or a commit and the working beliefs when `to` is omitted). */
|
|
369
|
+
diff(from, to) {
|
|
370
|
+
const a = new Map(this.beliefsAt(from).map((b) => [b.id, b]));
|
|
371
|
+
const b = new Map((to ? this.beliefsAt(to) : this.beliefs()).map((x) => [x.id, x]));
|
|
372
|
+
const out = { added: [], changed: [], removed: [] };
|
|
373
|
+
for (const [id, nb] of b) {
|
|
374
|
+
const ob = a.get(id);
|
|
375
|
+
if (!ob)
|
|
376
|
+
out.added.push(nb);
|
|
377
|
+
else if (ob.version !== nb.version)
|
|
378
|
+
out.changed.push([ob, nb]);
|
|
379
|
+
}
|
|
380
|
+
for (const [id, ob] of a)
|
|
381
|
+
if (!b.has(id))
|
|
382
|
+
out.removed.push(ob);
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Binary search over history: given a commit where the agent behaved well
|
|
387
|
+
* and a later one where it didn't, finds the first bad commit. `isBad`
|
|
388
|
+
* receives the beliefs at a commit (time travel) — run the agent's test there.
|
|
389
|
+
*/
|
|
390
|
+
async bisect(opts) {
|
|
391
|
+
const good = this.resolve(opts.good);
|
|
392
|
+
const bad = this.resolve(opts.bad);
|
|
393
|
+
// Commits from good (exclusive) to bad (inclusive), oldest first.
|
|
394
|
+
const chain = this.log(bad).map((c) => c.id);
|
|
395
|
+
const gi = chain.indexOf(good);
|
|
396
|
+
if (gi < 0)
|
|
397
|
+
throw new Error("the good commit must be an ancestor of the bad commit");
|
|
398
|
+
const range = chain.slice(0, gi).reverse();
|
|
399
|
+
if (range.length === 0)
|
|
400
|
+
throw new Error("good and bad are the same commit");
|
|
401
|
+
const steps = [];
|
|
402
|
+
let lo = 0;
|
|
403
|
+
let hi = range.length - 1; // range[hi] is known bad
|
|
404
|
+
while (lo < hi) {
|
|
405
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
406
|
+
const id = range[mid];
|
|
407
|
+
const isBad = await opts.isBad(this.beliefsAt(id), this.getCommit(id));
|
|
408
|
+
steps.push({ commit: id, bad: isBad });
|
|
409
|
+
if (isBad)
|
|
410
|
+
hi = mid;
|
|
411
|
+
else
|
|
412
|
+
lo = mid + 1;
|
|
413
|
+
}
|
|
414
|
+
const first = this.getCommit(range[lo]);
|
|
415
|
+
const parent = first.manifest.parent;
|
|
416
|
+
return { commit: first, steps, tested: steps.length, candidates: range.length, diff: parent ? this.diff(parent, first.id) : { added: this.beliefsAt(first.id), changed: [], removed: [] } };
|
|
417
|
+
}
|
|
418
|
+
/** The episodes behind a belief version, decrypted where possible. */
|
|
419
|
+
blame(ref) {
|
|
420
|
+
const belief = this.belief(ref);
|
|
421
|
+
if (!belief)
|
|
422
|
+
throw new Error(`unknown belief ${ref}`);
|
|
423
|
+
const evidence = belief.evidence.map((id) => {
|
|
424
|
+
const stored = this.episode(id);
|
|
425
|
+
const episode = stored ?? (this.readOnly ? this.sharedEpisodeStub(id, belief.createdAt) : null);
|
|
426
|
+
if (!episode)
|
|
427
|
+
throw new Error(`belief ${ref} cites missing episode ${id}`);
|
|
428
|
+
return { episode, content: !stored || episode.forgottenAt ? null : this.read(id) };
|
|
429
|
+
}).sort((a, b) => a.episode.ts - b.episode.ts);
|
|
430
|
+
return { belief, history: this.history(belief.id).filter((b) => b.version <= belief.version), evidence };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* In a read-only repo rebuilt from an audit bundle the raw episodes aren't
|
|
434
|
+
* present (they stay with the agent). Commits still record each evidence
|
|
435
|
+
* episode's Walrus blob ID, so blame can point at it.
|
|
436
|
+
*/
|
|
437
|
+
sharedEpisodeStub(id, ts) {
|
|
438
|
+
const blobId = this.head() ? this.log().map((c) => c.manifest.episodes[id]).find(Boolean) : undefined;
|
|
439
|
+
return { id, ts, source: "agent", summary: "kept private by the agent", blobId, keyId: "", contentHash: "", envelope: { iv: "", ct: "", tag: "" } };
|
|
440
|
+
}
|
|
441
|
+
// ---- Receipts --------------------------------------------------------------
|
|
442
|
+
/**
|
|
443
|
+
* Records a signed receipt for a decision: which committed beliefs it used,
|
|
444
|
+
* with Merkle proofs that they were in the HEAD commit.
|
|
445
|
+
*/
|
|
446
|
+
decide(input) {
|
|
447
|
+
const head = this.head();
|
|
448
|
+
if (!head)
|
|
449
|
+
throw new Error("commit before making decisions — receipts point at a commit");
|
|
450
|
+
const commit = this.getCommit(head);
|
|
451
|
+
const atHead = new Map(this.beliefsAt(head).map((b) => [b.id, b]));
|
|
452
|
+
const used = input.beliefs.map((ref) => {
|
|
453
|
+
const b = ref.includes("@") ? this.belief(ref) : atHead.get(ref);
|
|
454
|
+
if (!b || !commit.manifest.beliefs.includes(beliefKey(b)))
|
|
455
|
+
throw new Error(`belief ${ref} is not in the HEAD commit — commit first`);
|
|
456
|
+
return b;
|
|
457
|
+
});
|
|
458
|
+
const tree = buildCommitTree(this.beliefsAt(head), commit.manifest.tombstones);
|
|
459
|
+
const keys = [...new Set(used.map(beliefKey))].sort();
|
|
460
|
+
const body = {
|
|
461
|
+
engram: "receipt",
|
|
462
|
+
v: 1,
|
|
463
|
+
agent: this.agent,
|
|
464
|
+
commitId: head,
|
|
465
|
+
ts: Date.now(),
|
|
466
|
+
action: input.action,
|
|
467
|
+
inputHash: sha256Hex(canonical(input.input ?? null)),
|
|
468
|
+
beliefs: keys,
|
|
469
|
+
proofs: Object.fromEntries(keys.map((k) => [k, proofFor(tree, k)])),
|
|
470
|
+
agentPubKey: this.publicKey,
|
|
471
|
+
};
|
|
472
|
+
const id = `r_${sha256Hex(canonical(body)).slice(0, 16)}`;
|
|
473
|
+
const receipt = { ...body, id, signature: this.signRaw(canonical({ ...body, id })) };
|
|
474
|
+
this.store.put("receipts", id, receipt);
|
|
475
|
+
return receipt;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The runtime hook: gives `fn` the committed beliefs relevant to `task`,
|
|
479
|
+
* then records a signed receipt for what it decided and which beliefs it used.
|
|
480
|
+
* Before the first commit, `fn` gets no beliefs and no receipt is written.
|
|
481
|
+
*/
|
|
482
|
+
async act(task, fn) {
|
|
483
|
+
const head = this.head();
|
|
484
|
+
let beliefs = [];
|
|
485
|
+
if (head) {
|
|
486
|
+
const committed = new Map(this.beliefsAt(head).map((b) => [b.id, b]));
|
|
487
|
+
const relevant = (await this.relevant(task.query, task.limit ?? 6)).map((b) => committed.get(b.id)).filter((b) => !!b);
|
|
488
|
+
// Always include committed beliefs that `relevant` missed only if there are few of them.
|
|
489
|
+
beliefs = committed.size <= (task.limit ?? 6) ? [...committed.values()] : relevant;
|
|
490
|
+
}
|
|
491
|
+
const out = await fn(beliefs);
|
|
492
|
+
const allowed = new Set(beliefs.map((b) => b.id));
|
|
493
|
+
const used = [...new Set(out.used.map((u) => u.split("@")[0]))].filter((u) => allowed.has(u));
|
|
494
|
+
const receipt = head ? this.decide({ action: out.action, input: task.input, beliefs: used }) : null;
|
|
495
|
+
return { result: out.result, action: out.action, receipt, beliefs };
|
|
496
|
+
}
|
|
497
|
+
receipts() {
|
|
498
|
+
return this.store.list("receipts").sort((a, b) => a.ts - b.ts);
|
|
499
|
+
}
|
|
500
|
+
receipt(id) {
|
|
501
|
+
if (!/^r_[0-9a-f]+$/.test(id))
|
|
502
|
+
return null;
|
|
503
|
+
if (id.length < 18) {
|
|
504
|
+
const hits = this.receipts().filter((r) => r.id.startsWith(id));
|
|
505
|
+
return hits.length === 1 ? hits[0] : null;
|
|
506
|
+
}
|
|
507
|
+
return this.store.get("receipts", id);
|
|
508
|
+
}
|
|
509
|
+
// ---- Verify ----------------------------------------------------------------
|
|
510
|
+
/**
|
|
511
|
+
* Checks a commit end to end: ID, signature, Merkle root rebuilt from the
|
|
512
|
+
* beliefs, the parent chain, the manifest on Walrus Memory, and the Sui anchor.
|
|
513
|
+
*/
|
|
514
|
+
async verifyCommit(ref, opts = {}) {
|
|
515
|
+
const checks = [];
|
|
516
|
+
const add = (name, ok, detail, skipped = false) => checks.push({ name, ok, detail, skipped });
|
|
517
|
+
const id = this.resolve(ref);
|
|
518
|
+
const c = this.getCommit(id);
|
|
519
|
+
const m = c.manifest;
|
|
520
|
+
add("id", commitId(m) === c.id, "sha256 of the manifest matches the commit ID");
|
|
521
|
+
add("signature", m.agentPubKey === this.publicKey && verifyManifestSignature(m, c.signature), `Ed25519 by agent ${m.agentPubKey.slice(0, 12)}…`);
|
|
522
|
+
let rootOk = false;
|
|
523
|
+
let rootDetail = "";
|
|
524
|
+
try {
|
|
525
|
+
const beliefs = m.beliefs.map((k) => {
|
|
526
|
+
const b = this.belief(k);
|
|
527
|
+
if (!b)
|
|
528
|
+
throw new Error(`missing belief ${k}`);
|
|
529
|
+
return b;
|
|
530
|
+
});
|
|
531
|
+
const root = buildCommitTree(beliefs, m.tombstones).tree.root;
|
|
532
|
+
rootOk = root === m.merkleRoot;
|
|
533
|
+
rootDetail = rootOk ? `${m.beliefs.length} beliefs + ${m.tombstones.length} tombstones → root ${m.merkleRoot.slice(0, 12)}…` : "beliefs on disk do not match the committed root (tampered?)";
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
rootDetail = err.message;
|
|
537
|
+
}
|
|
538
|
+
add("merkle root", rootOk, rootDetail);
|
|
539
|
+
let chainOk = true;
|
|
540
|
+
let chainDetail = "";
|
|
541
|
+
let depth = 0;
|
|
542
|
+
for (let p = m.parent; p; depth++) {
|
|
543
|
+
const pc = this.getCommit(p);
|
|
544
|
+
if (!pc || commitId(pc.manifest) !== pc.id || !verifyManifestSignature(pc.manifest, pc.signature)) {
|
|
545
|
+
chainOk = false;
|
|
546
|
+
chainDetail = `parent ${p.slice(0, 8)} is missing or invalid`;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
p = pc.manifest.parent;
|
|
550
|
+
}
|
|
551
|
+
add("history", chainOk, chainOk ? `${depth} earlier commit(s), all signed and hash-linked` : chainDetail);
|
|
552
|
+
if (opts.walrus === false)
|
|
553
|
+
add("walrus", true, "skipped", true);
|
|
554
|
+
else if (!c.manifestBlobId)
|
|
555
|
+
add("walrus", false, "manifest was never stored on Walrus");
|
|
556
|
+
else {
|
|
557
|
+
try {
|
|
558
|
+
const hits = await this.remote.search(this.config.namespaces.commits, `Engram commit ${c.id}`, 10, { deep: true });
|
|
559
|
+
const hit = hits.find((h) => h.blobId === c.manifestBlobId);
|
|
560
|
+
const ok = !!hit && hit.text === commitText(c.id, m, c.signature);
|
|
561
|
+
add("walrus", ok, ok ? `manifest recalled from Walrus Memory, blob ${c.manifestBlobId.slice(0, 10)}…` : hit ? "manifest on Walrus differs from local copy" : "manifest not found in Walrus Memory recall");
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
add("walrus", true, `could not reach Walrus Memory: ${err.message}`, true);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (opts.chain === false)
|
|
568
|
+
add("sui anchor", true, "skipped", true);
|
|
569
|
+
else if (!this.anchorer)
|
|
570
|
+
add("sui anchor", true, "no anchorer configured", true);
|
|
571
|
+
else if (!c.anchor || !c.manifestBlobId)
|
|
572
|
+
add("sui anchor", false, "commit has no anchor yet (run `engram anchor`)");
|
|
573
|
+
else {
|
|
574
|
+
try {
|
|
575
|
+
const r = await this.anchorer.check(c.anchor, c.manifestBlobId, c);
|
|
576
|
+
const timeOk = !r.timestampMs || r.timestampMs >= m.ts - 5 * 60_000;
|
|
577
|
+
add("sui anchor", r.ok && timeOk, r.ok && !timeOk ? "anchor is older than the commit — impossible" : r.detail);
|
|
578
|
+
if (r.ok && r.timestampMs)
|
|
579
|
+
add("anchored at", true, new Date(r.timestampMs).toISOString());
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
add("sui anchor", true, `could not reach Sui RPC: ${err.message}`, true);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { ok: checks.every((x) => x.ok), checks };
|
|
586
|
+
}
|
|
587
|
+
/** Checks a receipt: its signature, each belief's Merkle proof, then the commit it points at. */
|
|
588
|
+
async verifyReceipt(id, opts = {}) {
|
|
589
|
+
const r = this.receipt(id);
|
|
590
|
+
if (!r)
|
|
591
|
+
throw new Error(`unknown receipt ${id}`);
|
|
592
|
+
const checks = [];
|
|
593
|
+
const { signature, blobId: _b, ...body } = r;
|
|
594
|
+
checks.push({ name: "receipt signature", ok: r.agentPubKey === this.publicKey && verifySignature(canonical(body), signature, r.agentPubKey), detail: `"${r.action}" signed by the agent` });
|
|
595
|
+
const c = this.getCommit(r.commitId);
|
|
596
|
+
if (!c)
|
|
597
|
+
return { ok: false, checks: [...checks, { name: "commit", ok: false, detail: `receipt points at unknown commit ${r.commitId.slice(0, 8)}` }] };
|
|
598
|
+
const bad = r.beliefs.filter((k) => {
|
|
599
|
+
const b = this.belief(k);
|
|
600
|
+
const proof = r.proofs[k];
|
|
601
|
+
return !b || !proof || !verifyBeliefProof(b, proof, c.manifest.merkleRoot);
|
|
602
|
+
});
|
|
603
|
+
checks.push({ name: "belief proofs", ok: bad.length === 0, detail: bad.length ? `invalid proof for ${bad.join(", ")}` : `${r.beliefs.length} belief(s) proven to be in commit ${c.id.slice(0, 8)}` });
|
|
604
|
+
checks.push({ name: "timing", ok: r.ts >= c.manifest.ts, detail: "decision made after its commit" });
|
|
605
|
+
const commitReport = await this.verifyCommit(c.id, opts);
|
|
606
|
+
checks.push(...commitReport.checks.map((x) => ({ ...x, name: `commit ${x.name}` })));
|
|
607
|
+
return { ok: checks.every((x) => x.ok), checks };
|
|
608
|
+
}
|
|
609
|
+
// ---- Forget ----------------------------------------------------------------
|
|
610
|
+
/**
|
|
611
|
+
* Crypto-shredding: destroys the episode's key so its ciphertext (local and
|
|
612
|
+
* on Walrus) can never be decrypted again. Beliefs that relied on it lose
|
|
613
|
+
* that evidence — and are retracted if nothing else supports them. The next
|
|
614
|
+
* commit records a tombstone.
|
|
615
|
+
*/
|
|
616
|
+
forget(episodeId, reason = "forgotten on request") {
|
|
617
|
+
const ep = this.episode(episodeId);
|
|
618
|
+
if (!ep)
|
|
619
|
+
throw new Error(`unknown episode ${episodeId}`);
|
|
620
|
+
if (ep.forgottenAt)
|
|
621
|
+
throw new Error(`episode ${episodeId} was already forgotten`);
|
|
622
|
+
this.vault.destroy(ep.keyId);
|
|
623
|
+
const episode = { ...ep, forgottenAt: Date.now(), summary: undefined };
|
|
624
|
+
this.store.put("episodes", ep.id, episode);
|
|
625
|
+
const out = { episode, revised: [], retracted: [] };
|
|
626
|
+
for (const b of this.beliefs().filter((x) => x.evidence.includes(episodeId))) {
|
|
627
|
+
const rest = b.evidence.filter((x) => x !== episodeId);
|
|
628
|
+
if (rest.length === 0)
|
|
629
|
+
out.retracted.push(this.applyOp({ op: "retract", belief: b.id, reason: `${reason}: its only evidence ${episodeId} was forgotten` }));
|
|
630
|
+
else
|
|
631
|
+
out.revised.push(this.applyOp({ op: "revise", belief: b.id, statement: b.statement, confidence: b.confidence, evidence: rest, tags: b.tags, reason: `${reason}: evidence ${episodeId} removed` }));
|
|
632
|
+
}
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
// ---- Sync ----------------------------------------------------------------
|
|
636
|
+
/** Uploads everything not yet on Walrus Memory and records the blob IDs. */
|
|
637
|
+
async push() {
|
|
638
|
+
const ns = this.config.namespaces;
|
|
639
|
+
// A forgotten episode that never left this machine doesn't need to.
|
|
640
|
+
const eps = this.episodes().filter((e) => !e.blobId && !e.forgottenAt);
|
|
641
|
+
const bels = this.beliefVersions().filter((b) => !b.blobId);
|
|
642
|
+
const recs = this.receipts().filter((r) => !r.blobId);
|
|
643
|
+
const items = [
|
|
644
|
+
...eps.map((e) => ({ namespace: ns.episodes, text: canonical(e.envelope) })),
|
|
645
|
+
...bels.map((b) => ({ namespace: ns.beliefs, text: beliefText(b) })),
|
|
646
|
+
...recs.map((r) => ({ namespace: ns.receipts, text: receiptText(r) })),
|
|
647
|
+
];
|
|
648
|
+
const blobIds = await this.remote.putMany(items);
|
|
649
|
+
eps.forEach((e, i) => this.store.put("episodes", e.id, { ...this.episode(e.id), blobId: blobIds[i] }));
|
|
650
|
+
bels.forEach((b, i) => this.store.put("beliefs", beliefKey(b), { ...b, blobId: blobIds[eps.length + i] }));
|
|
651
|
+
recs.forEach((r, i) => this.store.put("receipts", r.id, { ...r, blobId: blobIds[eps.length + bels.length + i] }));
|
|
652
|
+
return { episodes: eps.length, beliefs: bels.length, receipts: recs.length };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const BELIEF_MARK = "\n\nengram:";
|
|
656
|
+
/** How a belief is written to Walrus Memory: readable statement first (for semantic recall), then the full record. */
|
|
657
|
+
export function beliefText(b) {
|
|
658
|
+
return `${b.statement}${BELIEF_MARK}${canonical({ ...b, blobId: undefined })}`;
|
|
659
|
+
}
|
|
660
|
+
/** Returns "id@version" from a belief text, or null if it isn't one. */
|
|
661
|
+
export function parseBeliefText(text) {
|
|
662
|
+
const i = text.lastIndexOf(BELIEF_MARK);
|
|
663
|
+
if (i < 0)
|
|
664
|
+
return null;
|
|
665
|
+
try {
|
|
666
|
+
const b = JSON.parse(text.slice(i + BELIEF_MARK.length));
|
|
667
|
+
return typeof b.id === "string" && typeof b.version === "number" ? beliefKey(b) : null;
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** How a commit is written to Walrus Memory. */
|
|
674
|
+
export function commitText(id, manifest, signature) {
|
|
675
|
+
return `Engram commit ${id} by ${manifest.agent}: ${manifest.message}${BELIEF_MARK}${canonical({ id, manifest, signature })}`;
|
|
676
|
+
}
|
|
677
|
+
/** How a receipt is written to Walrus Memory. */
|
|
678
|
+
export function receiptText(r) {
|
|
679
|
+
return `Engram receipt ${r.id} by ${r.agent}: ${r.action}${BELIEF_MARK}${canonical({ ...r, blobId: undefined })}`;
|
|
680
|
+
}
|
|
681
|
+
//# sourceMappingURL=engram.js.map
|