@poa-box/agent 0.1.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/.env.agent.template +20 -0
- package/README.md +46 -0
- package/brain/Config/agent-config.json +14 -0
- package/brain/Config/brain-allowlist.json +20 -0
- package/brain/Identity/goals.template.md +23 -0
- package/brain/Identity/how-i-think.md +406 -0
- package/brain/Identity/who-i-am.template.md +34 -0
- package/brain/Knowledge/BOOTSTRAP.md +66 -0
- package/brain/Knowledge/audit-corpus-index.json +406 -0
- package/brain/Knowledge/discussions.json +245 -0
- package/brain/Knowledge/pop.brain.brainstorms.generated.md +48 -0
- package/brain/Knowledge/pop.brain.brainstorms.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.heuristics.snapshot.bin +0 -0
- package/brain/Knowledge/pop.brain.projects.generated.md +16 -0
- package/brain/Knowledge/pop.brain.projects.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.retros.generated.md +91 -0
- package/brain/Knowledge/pop.brain.retros.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.shared.generated.md +3811 -0
- package/brain/Knowledge/pop.brain.shared.genesis.bin +0 -0
- package/brain/Knowledge/projects.md +181 -0
- package/brain/Knowledge/risk-framework.md +90 -0
- package/brain/Knowledge/shared.md +416 -0
- package/brain/Knowledge/sprint-priorities.md +439 -0
- package/brain/Memory/.gitkeep +0 -0
- package/dist/commands/agent/daily-digest.d.ts +24 -0
- package/dist/commands/agent/daily-digest.js +336 -0
- package/dist/commands/agent/delegate.d.ts +12 -0
- package/dist/commands/agent/delegate.js +91 -0
- package/dist/commands/agent/deploy-to-org.d.ts +20 -0
- package/dist/commands/agent/deploy-to-org.js +154 -0
- package/dist/commands/agent/index.d.ts +2 -0
- package/dist/commands/agent/index.js +27 -0
- package/dist/commands/agent/init.d.ts +19 -0
- package/dist/commands/agent/init.js +303 -0
- package/dist/commands/agent/onboard.d.ts +22 -0
- package/dist/commands/agent/onboard.js +192 -0
- package/dist/commands/agent/paymaster-status.d.ts +14 -0
- package/dist/commands/agent/paymaster-status.js +130 -0
- package/dist/commands/agent/register.d.ts +21 -0
- package/dist/commands/agent/register.js +116 -0
- package/dist/commands/agent/setup-sponsorship.d.ts +22 -0
- package/dist/commands/agent/setup-sponsorship.js +154 -0
- package/dist/commands/agent/status.d.ts +12 -0
- package/dist/commands/agent/status.js +171 -0
- package/dist/commands/agent/triage.d.ts +12 -0
- package/dist/commands/agent/triage.js +503 -0
- package/dist/commands/brain/advance-stage.d.ts +42 -0
- package/dist/commands/brain/advance-stage.js +206 -0
- package/dist/commands/brain/allowlist.d.ts +30 -0
- package/dist/commands/brain/allowlist.js +274 -0
- package/dist/commands/brain/append-lesson.d.ts +55 -0
- package/dist/commands/brain/append-lesson.js +245 -0
- package/dist/commands/brain/brainstorm.d.ts +154 -0
- package/dist/commands/brain/brainstorm.js +573 -0
- package/dist/commands/brain/daemon.d.ts +31 -0
- package/dist/commands/brain/daemon.js +348 -0
- package/dist/commands/brain/doctor.d.ts +27 -0
- package/dist/commands/brain/doctor.js +497 -0
- package/dist/commands/brain/edit-lesson.d.ts +51 -0
- package/dist/commands/brain/edit-lesson.js +248 -0
- package/dist/commands/brain/import-snapshot.d.ts +68 -0
- package/dist/commands/brain/import-snapshot.js +177 -0
- package/dist/commands/brain/index.d.ts +2 -0
- package/dist/commands/brain/index.js +67 -0
- package/dist/commands/brain/list.d.ts +21 -0
- package/dist/commands/brain/list.js +83 -0
- package/dist/commands/brain/migrate-projects.d.ts +44 -0
- package/dist/commands/brain/migrate-projects.js +209 -0
- package/dist/commands/brain/migrate.d.ts +74 -0
- package/dist/commands/brain/migrate.js +306 -0
- package/dist/commands/brain/new-project.d.ts +53 -0
- package/dist/commands/brain/new-project.js +226 -0
- package/dist/commands/brain/read.d.ts +24 -0
- package/dist/commands/brain/read.js +81 -0
- package/dist/commands/brain/remove-lesson.d.ts +47 -0
- package/dist/commands/brain/remove-lesson.js +206 -0
- package/dist/commands/brain/remove-project.d.ts +36 -0
- package/dist/commands/brain/remove-project.js +177 -0
- package/dist/commands/brain/retro-file-tasks.d.ts +84 -0
- package/dist/commands/brain/retro-file-tasks.js +372 -0
- package/dist/commands/brain/retro-list.d.ts +28 -0
- package/dist/commands/brain/retro-list.js +125 -0
- package/dist/commands/brain/retro-mark-change.d.ts +58 -0
- package/dist/commands/brain/retro-mark-change.js +176 -0
- package/dist/commands/brain/retro-remove.d.ts +36 -0
- package/dist/commands/brain/retro-remove.js +142 -0
- package/dist/commands/brain/retro-respond.d.ts +56 -0
- package/dist/commands/brain/retro-respond.js +250 -0
- package/dist/commands/brain/retro-show.d.ts +23 -0
- package/dist/commands/brain/retro-show.js +100 -0
- package/dist/commands/brain/retro-start.d.ts +55 -0
- package/dist/commands/brain/retro-start.js +311 -0
- package/dist/commands/brain/search.d.ts +48 -0
- package/dist/commands/brain/search.js +190 -0
- package/dist/commands/brain/snapshot.d.ts +32 -0
- package/dist/commands/brain/snapshot.js +243 -0
- package/dist/commands/brain/status.d.ts +15 -0
- package/dist/commands/brain/status.js +166 -0
- package/dist/commands/brain/subscribe.d.ts +28 -0
- package/dist/commands/brain/subscribe.js +90 -0
- package/dist/commands/brain/tag.d.ts +46 -0
- package/dist/commands/brain/tag.js +192 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/lib/brain-daemon.d.ts +126 -0
- package/dist/lib/brain-daemon.js +811 -0
- package/dist/lib/brain-membership.d.ts +58 -0
- package/dist/lib/brain-membership.js +115 -0
- package/dist/lib/brain-migrate-projects.d.ts +43 -0
- package/dist/lib/brain-migrate-projects.js +247 -0
- package/dist/lib/brain-migrate.d.ts +77 -0
- package/dist/lib/brain-migrate.js +328 -0
- package/dist/lib/brain-ops.d.ts +271 -0
- package/dist/lib/brain-ops.js +571 -0
- package/dist/lib/brain-paths.d.ts +15 -0
- package/dist/lib/brain-paths.js +33 -0
- package/dist/lib/brain-projections.d.ts +216 -0
- package/dist/lib/brain-projections.js +829 -0
- package/dist/lib/brain-schemas.d.ts +36 -0
- package/dist/lib/brain-schemas.js +316 -0
- package/dist/lib/brain-signing.d.ts +103 -0
- package/dist/lib/brain-signing.js +256 -0
- package/dist/lib/brain.d.ts +198 -0
- package/dist/lib/brain.js +1057 -0
- package/dist/pop-agent.d.ts +1 -0
- package/dist/pop-agent.js +18 -0
- package/docs/agent.md +126 -0
- package/docs/agents/brain-anti-entropy.md +127 -0
- package/docs/agents/brain-cross-device-onboarding.md +210 -0
- package/docs/agents/brain-cross-machine-smoke.md +241 -0
- package/docs/agents/brain-layer-setup.md +725 -0
- package/docs/agents/offboarding-protocol.md +188 -0
- package/docs/agents/onboarding-protocol.md +243 -0
- package/docs/agents/running-an-agent.md +200 -0
- package/docs/brain.md +560 -0
- package/package.json +61 -0
- package/scripts/apply.sh +140 -0
- package/scripts/onboard.sh +205 -0
- package/scripts/setup-agent.ts +272 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Brain layer — peer-to-peer CRDT substrate for shared agent thinking.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the git-tracked markdown files (shared.md, projects.md,
|
|
6
|
+
* discussions.json, goals.md) with Automerge documents synced over
|
|
7
|
+
* libp2p-gossipsub + Bitswap via an embedded Helia node. No Argus-operated
|
|
8
|
+
* service in the hot path; peer-to-peer only.
|
|
9
|
+
*
|
|
10
|
+
* This file intentionally isolates the ESM import boundary. Helia and its
|
|
11
|
+
* libp2p dependencies are pure ESM, while the rest of the project compiles
|
|
12
|
+
* to CommonJS. Dynamic `await import()` is the clean way to bridge the gap
|
|
13
|
+
* without touching every other source file's module system.
|
|
14
|
+
*
|
|
15
|
+
* Plan: /Users/hudsonheadley/.claude/plans/cheeky-nibbling-raven.md
|
|
16
|
+
*
|
|
17
|
+
* Current status: MVP step 1 — initialize a Helia node, report peer info.
|
|
18
|
+
* CRDT/Automerge/gossipsub layers come in subsequent steps.
|
|
19
|
+
*/
|
|
20
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
23
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
24
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
25
|
+
}
|
|
26
|
+
Object.defineProperty(o, k2, desc);
|
|
27
|
+
}) : (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
o[k2] = m[k];
|
|
30
|
+
}));
|
|
31
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
32
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
33
|
+
}) : function(o, v) {
|
|
34
|
+
o["default"] = v;
|
|
35
|
+
});
|
|
36
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
37
|
+
var ownKeys = function(o) {
|
|
38
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
39
|
+
var ar = [];
|
|
40
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
41
|
+
return ar;
|
|
42
|
+
};
|
|
43
|
+
return ownKeys(o);
|
|
44
|
+
};
|
|
45
|
+
return function (mod) {
|
|
46
|
+
if (mod && mod.__esModule) return mod;
|
|
47
|
+
var result = {};
|
|
48
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
49
|
+
__setModuleDefault(result, mod);
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
})();
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.getBrainHome = getBrainHome;
|
|
55
|
+
exports.topicForDoc = topicForDoc;
|
|
56
|
+
exports.initBrainNode = initBrainNode;
|
|
57
|
+
exports.getBrainNodeInfo = getBrainNodeInfo;
|
|
58
|
+
exports.publishBrainHead = publishBrainHead;
|
|
59
|
+
exports.subscribeBrainTopic = subscribeBrainTopic;
|
|
60
|
+
exports.stopBrainNode = stopBrainNode;
|
|
61
|
+
exports.openBrainDoc = openBrainDoc;
|
|
62
|
+
exports.applyBrainChange = applyBrainChange;
|
|
63
|
+
exports.importBrainDoc = importBrainDoc;
|
|
64
|
+
exports.listBrainDocs = listBrainDocs;
|
|
65
|
+
exports.fetchAndMergeRemoteHead = fetchAndMergeRemoteHead;
|
|
66
|
+
exports.readBrainDoc = readBrainDoc;
|
|
67
|
+
const os_1 = require("os");
|
|
68
|
+
const path_1 = require("path");
|
|
69
|
+
const brain_paths_1 = require("./brain-paths");
|
|
70
|
+
const fs_1 = require("fs");
|
|
71
|
+
const brain_signing_1 = require("./brain-signing");
|
|
72
|
+
/**
|
|
73
|
+
* Where the brain layer persists its blocks and state.
|
|
74
|
+
* One directory per agent. Survives heartbeat cycles, not in git.
|
|
75
|
+
*/
|
|
76
|
+
function getBrainHome() {
|
|
77
|
+
const home = process.env.POP_BRAIN_HOME || (0, path_1.join)((0, os_1.homedir)(), '.pop-agent', 'brain');
|
|
78
|
+
if (!(0, fs_1.existsSync)(home))
|
|
79
|
+
(0, fs_1.mkdirSync)(home, { recursive: true });
|
|
80
|
+
return home;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Canonical public IPFS bootstrap peers. These are the Protocol Labs
|
|
84
|
+
* public bootstrap nodes, used by `go-ipfs`, `kubo`, and every default
|
|
85
|
+
* Helia install. They're multi-operator, censorship-resistant at the
|
|
86
|
+
* substrate level, and free to use.
|
|
87
|
+
*
|
|
88
|
+
* Brain peers join the DHT via these + use Circuit Relay v2 to punch
|
|
89
|
+
* through NAT when both sides are behind firewalls. On a fresh machine
|
|
90
|
+
* with no static peer list, these are the only way to be discoverable
|
|
91
|
+
* from another agent running anywhere on the internet.
|
|
92
|
+
*/
|
|
93
|
+
const DEFAULT_BOOTSTRAP_PEERS = [
|
|
94
|
+
'/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN',
|
|
95
|
+
'/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa',
|
|
96
|
+
'/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb',
|
|
97
|
+
'/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt',
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Path to the persistent libp2p PeerId private key. Lives alongside the
|
|
101
|
+
* blockstore under <brain-home>/peer-key.json. Format:
|
|
102
|
+
* { keyType: "Ed25519", privateKey: "0x<hex>" }
|
|
103
|
+
*
|
|
104
|
+
* Generated once per brain home on first boot; reused on every
|
|
105
|
+
* subsequent boot so the PeerId is stable across restarts. Without
|
|
106
|
+
* this, every process gets a random PeerId and any static peer list
|
|
107
|
+
* (or reputation-tracking peer) goes stale instantly.
|
|
108
|
+
*
|
|
109
|
+
* Security note: this file sits next to POP_PRIVATE_KEY in the same
|
|
110
|
+
* filesystem under the same threat model — anyone who can read one
|
|
111
|
+
* can read the other. No encryption; no passphrase. Operators who
|
|
112
|
+
* want to rotate their PeerId delete the file manually.
|
|
113
|
+
*/
|
|
114
|
+
function getPeerKeyPath() {
|
|
115
|
+
return (0, path_1.join)(getBrainHome(), 'peer-key.json');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Load the persisted libp2p private key, or generate + persist a new
|
|
119
|
+
* one if none exists. Returns the private key object that libp2p@2.x
|
|
120
|
+
* expects for its `privateKey` createLibp2p option, plus a flag
|
|
121
|
+
* indicating whether the key was loaded or freshly generated (for
|
|
122
|
+
* operator-visible status output).
|
|
123
|
+
*
|
|
124
|
+
* Corrupt or unreadable key files fall back to fresh generation with
|
|
125
|
+
* a warning — never crash the node over a half-written JSON file.
|
|
126
|
+
*/
|
|
127
|
+
async function getOrCreatePeerPrivateKey() {
|
|
128
|
+
const path = getPeerKeyPath();
|
|
129
|
+
const { generateKeyPair, privateKeyFromProtobuf, privateKeyToProtobuf, } = await esmImport('@libp2p/crypto/keys');
|
|
130
|
+
if ((0, fs_1.existsSync)(path)) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
|
|
133
|
+
if (typeof raw?.privateKey === 'string' && raw.privateKey.startsWith('0x')) {
|
|
134
|
+
const bytes = Uint8Array.from(Buffer.from(raw.privateKey.slice(2), 'hex'));
|
|
135
|
+
// Stored as a protobuf-framed private key (keyType discriminator
|
|
136
|
+
// + key material — libp2p's canonical on-disk format). The hex
|
|
137
|
+
// contains the output of privateKeyToProtobuf, not .raw bytes.
|
|
138
|
+
const privateKey = privateKeyFromProtobuf(bytes);
|
|
139
|
+
return { privateKey, source: 'persisted' };
|
|
140
|
+
}
|
|
141
|
+
throw new Error('malformed peer-key.json — missing hex privateKey');
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
145
|
+
console.error(`[brain] peer-key.json unreadable (${err.message}) — regenerating`);
|
|
146
|
+
}
|
|
147
|
+
// Fall through to fresh generation.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Ed25519 is the default for libp2p PeerIds — small, fast, ubiquitous.
|
|
151
|
+
const privateKey = await generateKeyPair('Ed25519');
|
|
152
|
+
// Serialize via privateKeyToProtobuf so the on-disk format is the
|
|
153
|
+
// canonical libp2p wire format. The earlier version of this code
|
|
154
|
+
// stored privateKey.raw (32 raw Ed25519 bytes) which does NOT
|
|
155
|
+
// round-trip through privateKeyFromProtobuf — that path expects the
|
|
156
|
+
// protobuf envelope with the keyType discriminator. Without it the
|
|
157
|
+
// load silently fails and we regenerate a new PeerId on every boot.
|
|
158
|
+
const protobufBytes = privateKeyToProtobuf(privateKey);
|
|
159
|
+
const hex = '0x' + Buffer.from(protobufBytes).toString('hex');
|
|
160
|
+
(0, fs_1.writeFileSync)(path, JSON.stringify({ keyType: 'Ed25519', privateKey: hex }, null, 2));
|
|
161
|
+
return { privateKey, source: 'freshly-generated' };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Track whether the currently-cached node was booted with a persisted
|
|
165
|
+
* or freshly-generated PeerId. Surfaced in getBrainNodeInfo so status
|
|
166
|
+
* output can tell the operator which path was taken.
|
|
167
|
+
*/
|
|
168
|
+
let cachedPeerIdSource = null;
|
|
169
|
+
/**
|
|
170
|
+
* Gossipsub topic name for a brain doc. Versioned so we can bump the
|
|
171
|
+
* wire format later without silently crossing streams with old peers.
|
|
172
|
+
*/
|
|
173
|
+
function topicForDoc(docId) {
|
|
174
|
+
return `pop/brain/${docId}/v1`;
|
|
175
|
+
}
|
|
176
|
+
let cachedNode = null;
|
|
177
|
+
/**
|
|
178
|
+
* Initialize (or return cached) embedded Helia node.
|
|
179
|
+
*
|
|
180
|
+
* Uses a NodeFS blockstore at <brain-home>/helia-blocks so state
|
|
181
|
+
* persists across CLI invocations — important because every `pop`
|
|
182
|
+
* invocation is a fresh process but the brain state must be durable.
|
|
183
|
+
*
|
|
184
|
+
* Returns the live Helia instance. Caller is responsible for calling
|
|
185
|
+
* `stopBrainNode()` before exit if they care about a clean shutdown.
|
|
186
|
+
*/
|
|
187
|
+
// TypeScript's `commonjs` module target compiles `await import('x')` to
|
|
188
|
+
// `require('x')`. That works for dual-format packages (like helia@5+) but
|
|
189
|
+
// fails for ESM-only packages (like blockstore-fs) with "No exports main
|
|
190
|
+
// defined." The Function constructor is opaque to TypeScript, so this
|
|
191
|
+
// performs a *real* Node dynamic import at runtime.
|
|
192
|
+
const esmImport = new Function('s', 'return import(s)');
|
|
193
|
+
async function initBrainNode() {
|
|
194
|
+
if (cachedNode)
|
|
195
|
+
return cachedNode;
|
|
196
|
+
// Dynamic ESM imports — Helia and its sibling libraries are pure ESM;
|
|
197
|
+
// this file compiles to CJS.
|
|
198
|
+
const { createHelia } = await esmImport('helia');
|
|
199
|
+
const { FsBlockstore } = await esmImport('blockstore-fs');
|
|
200
|
+
const { createLibp2p } = await esmImport('libp2p');
|
|
201
|
+
const { tcp } = await esmImport('@libp2p/tcp');
|
|
202
|
+
const { mdns } = await esmImport('@libp2p/mdns');
|
|
203
|
+
const { bootstrap } = await esmImport('@libp2p/bootstrap');
|
|
204
|
+
const { noise } = await esmImport('@chainsafe/libp2p-noise');
|
|
205
|
+
const { yamux } = await esmImport('@chainsafe/libp2p-yamux');
|
|
206
|
+
const { identify } = await esmImport('@libp2p/identify');
|
|
207
|
+
const { gossipsub } = await esmImport('@chainsafe/libp2p-gossipsub');
|
|
208
|
+
const { circuitRelayTransport } = await esmImport('@libp2p/circuit-relay-v2');
|
|
209
|
+
const { autoNAT } = await esmImport('@libp2p/autonat');
|
|
210
|
+
const brainHome = getBrainHome();
|
|
211
|
+
const blockstorePath = (0, path_1.join)(brainHome, 'helia-blocks');
|
|
212
|
+
if (!(0, fs_1.existsSync)(blockstorePath))
|
|
213
|
+
(0, fs_1.mkdirSync)(blockstorePath, { recursive: true });
|
|
214
|
+
// Persistent blockstore — CRDT blocks survive across CLI invocations.
|
|
215
|
+
// The `as any` coercion bypasses a TypeScript version-skew between
|
|
216
|
+
// blockstore-fs@2's bundled `interface-*` packages and Helia's top-level
|
|
217
|
+
// copy. Runtime shape is correct; types can't see through duplicated
|
|
218
|
+
// class declarations.
|
|
219
|
+
const blockstore = new FsBlockstore(blockstorePath);
|
|
220
|
+
// Persistent libp2p PeerId. Without this, every process gets a random
|
|
221
|
+
// PeerId and any static peer list / reputation tracking goes stale
|
|
222
|
+
// immediately. Generated once per brain home on first boot; reused on
|
|
223
|
+
// every subsequent boot. File format documented in getOrCreatePeerPrivateKey.
|
|
224
|
+
const { privateKey, source } = await getOrCreatePeerPrivateKey();
|
|
225
|
+
cachedPeerIdSource = source;
|
|
226
|
+
// libp2p config: now wired for cross-machine sync.
|
|
227
|
+
//
|
|
228
|
+
// Peer discovery: mdns (same-LAN), bootstrap (public IPFS bootstrap
|
|
229
|
+
// peers for WAN discovery). The bootstrap list is the canonical
|
|
230
|
+
// Protocol Labs list — multi-operator, censorship-resistant, free.
|
|
231
|
+
//
|
|
232
|
+
// Transports: tcp for direct dial AND circuitRelayTransport for NAT
|
|
233
|
+
// traversal via public Circuit Relay v2 nodes. When this peer is behind
|
|
234
|
+
// NAT and can't be reached directly, AutoNAT detects it and libp2p
|
|
235
|
+
// arranges to be reachable via a public relay.
|
|
236
|
+
//
|
|
237
|
+
// Services: identify (protocol handshake), pubsub (gossipsub for head
|
|
238
|
+
// announcements), autoNAT (reachability detection).
|
|
239
|
+
//
|
|
240
|
+
// gossipsub is configured with allowPublishToZeroTopicPeers so that a
|
|
241
|
+
// publisher whose local topic has no subscribers yet doesn't throw —
|
|
242
|
+
// the write has already persisted locally and the announcement is
|
|
243
|
+
// best-effort. `emitSelf: false` because we don't want local subscribers
|
|
244
|
+
// echoing back our own publishes.
|
|
245
|
+
// HB#364: optional fixed listen port via POP_BRAIN_LISTEN_PORT.
|
|
246
|
+
// When set, the daemon binds TCP to a predictable port so committed
|
|
247
|
+
// static peer lists (brain-peers.json) remain valid across restarts.
|
|
248
|
+
// When unset, fall back to random port (libp2p tcp/0) for ephemeral
|
|
249
|
+
// CLI invocations that don't need to be addressable. Cross-device
|
|
250
|
+
// onboarding is gated on this being set on at least one side.
|
|
251
|
+
const rawListenPort = process.env.POP_BRAIN_LISTEN_PORT?.trim();
|
|
252
|
+
const listenPort = rawListenPort && /^\d+$/.test(rawListenPort) ? Number(rawListenPort) : 0;
|
|
253
|
+
const listenAddrs = [`/ip4/0.0.0.0/tcp/${listenPort}`];
|
|
254
|
+
const libp2p = await createLibp2p({
|
|
255
|
+
privateKey,
|
|
256
|
+
addresses: { listen: listenAddrs },
|
|
257
|
+
transports: [tcp(), circuitRelayTransport()],
|
|
258
|
+
streamMuxers: [yamux()],
|
|
259
|
+
connectionEncrypters: [noise()],
|
|
260
|
+
peerDiscovery: [
|
|
261
|
+
mdns(),
|
|
262
|
+
bootstrap({ list: DEFAULT_BOOTSTRAP_PEERS }),
|
|
263
|
+
],
|
|
264
|
+
services: {
|
|
265
|
+
identify: identify(),
|
|
266
|
+
autonat: autoNAT(),
|
|
267
|
+
pubsub: gossipsub({
|
|
268
|
+
allowPublishToZeroTopicPeers: true,
|
|
269
|
+
emitSelf: false,
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
cachedNode = await createHelia({ blockstore, libp2p });
|
|
274
|
+
// Auto-subscribe to all doc topics we already know about. Gossipsub's
|
|
275
|
+
// mesh formation is gated on topic membership being propagated over the
|
|
276
|
+
// pubsub control plane (one heartbeat interval, ~1s). Subscribing at
|
|
277
|
+
// init time — rather than lazily inside publishBrainHead — means that
|
|
278
|
+
// by the first write, any already-connected peer has had time to learn
|
|
279
|
+
// we're part of the same topic, so the publish actually reaches them.
|
|
280
|
+
try {
|
|
281
|
+
const pubsub = libp2p.services?.pubsub;
|
|
282
|
+
if (pubsub) {
|
|
283
|
+
const knownDocs = Object.keys(loadHeadsManifest());
|
|
284
|
+
for (const docId of knownDocs) {
|
|
285
|
+
pubsub.subscribe(topicForDoc(docId));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
291
|
+
console.error(`[brain] auto-subscribe failed: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return cachedNode;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Gather a human-readable summary of the local brain node state.
|
|
298
|
+
*/
|
|
299
|
+
async function getBrainNodeInfo() {
|
|
300
|
+
const helia = await initBrainNode();
|
|
301
|
+
const libp2p = helia.libp2p;
|
|
302
|
+
const peerId = libp2p.peerId.toString();
|
|
303
|
+
const listeningAddrs = libp2p.getMultiaddrs().map((ma) => ma.toString());
|
|
304
|
+
const connectedPeers = libp2p.getConnections().length;
|
|
305
|
+
// Helia version — package.json isn't trivially readable from dist/,
|
|
306
|
+
// so we probe the runtime module for a version marker and fall back.
|
|
307
|
+
let heliaVersion = 'unknown';
|
|
308
|
+
try {
|
|
309
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
310
|
+
heliaVersion = require('helia/package.json').version;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// package.json may not be exported; fall back to unknown
|
|
314
|
+
}
|
|
315
|
+
// Pubsub may not be present in very old libp2p configs; guard the lookup.
|
|
316
|
+
const pubsub = libp2p.services?.pubsub;
|
|
317
|
+
const subscribedTopics = pubsub?.getTopics?.() ?? [];
|
|
318
|
+
const topicPeerCounts = {};
|
|
319
|
+
for (const t of subscribedTopics) {
|
|
320
|
+
try {
|
|
321
|
+
topicPeerCounts[t] = pubsub.getSubscribers(t)?.length ?? 0;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
topicPeerCounts[t] = 0;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Bootstrap peer count: how many of the canonical Protocol Labs
|
|
328
|
+
// bootstrap peers (from DEFAULT_BOOTSTRAP_PEERS) are currently listed
|
|
329
|
+
// in libp2p's peer store. This is NOT the same as "connected peers"
|
|
330
|
+
// (bootstrap discovery populates the peer store even before a
|
|
331
|
+
// connection is established), but it's a useful reachability proxy:
|
|
332
|
+
// if this number is 0, the bootstrap DNS lookup failed or the DNS
|
|
333
|
+
// addresses haven't resolved yet.
|
|
334
|
+
let bootstrapPeerCount = 0;
|
|
335
|
+
try {
|
|
336
|
+
const bootstrapPeerIds = new Set(DEFAULT_BOOTSTRAP_PEERS
|
|
337
|
+
.map(addr => addr.split('/p2p/')[1])
|
|
338
|
+
.filter(Boolean));
|
|
339
|
+
const peers = await libp2p.peerStore.all();
|
|
340
|
+
bootstrapPeerCount = peers.filter((p) => bootstrapPeerIds.has(p.id?.toString?.())).length;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Peer store lookup failures are not worth crashing status output.
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
peerId,
|
|
347
|
+
peerIdSource: cachedPeerIdSource ?? 'freshly-generated',
|
|
348
|
+
listeningAddrs,
|
|
349
|
+
connectedPeers,
|
|
350
|
+
bootstrapPeerCount,
|
|
351
|
+
heliaVersion,
|
|
352
|
+
blockstorePath: (0, path_1.join)(getBrainHome(), 'helia-blocks'),
|
|
353
|
+
peerKeyPath: getPeerKeyPath(),
|
|
354
|
+
subscribedTopics,
|
|
355
|
+
topicPeerCounts,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Publish a head-CID announcement on a doc's gossipsub topic.
|
|
360
|
+
* Best-effort: if there are no peers yet (common at cold start) the
|
|
361
|
+
* publish is a no-op thanks to allowPublishToZeroTopicPeers. A thrown
|
|
362
|
+
* error is caught and logged — the local write has already succeeded,
|
|
363
|
+
* and missed announcements recover at next peer reconnect.
|
|
364
|
+
*
|
|
365
|
+
* Subscribes to the topic before publishing so that future inbound
|
|
366
|
+
* announcements on the same doc are received. Gossipsub requires a
|
|
367
|
+
* subscription on a topic before it will publish to it meaningfully.
|
|
368
|
+
*/
|
|
369
|
+
// Tracks topics we subscribed to this process so we can wait one
|
|
370
|
+
// gossipsub heartbeat after a FRESH subscribe before first publish —
|
|
371
|
+
// otherwise the peer hasn't been told we're in the topic yet and the
|
|
372
|
+
// message goes nowhere. Only a cold-start cost; subsequent writes on
|
|
373
|
+
// the same topic are instant.
|
|
374
|
+
const freshlySubscribedTopics = new Set();
|
|
375
|
+
async function publishBrainHead(docId, cid, author) {
|
|
376
|
+
const helia = await initBrainNode();
|
|
377
|
+
const pubsub = helia.libp2p.services?.pubsub;
|
|
378
|
+
if (!pubsub)
|
|
379
|
+
return;
|
|
380
|
+
const topic = topicForDoc(docId);
|
|
381
|
+
try {
|
|
382
|
+
// Subscribe if we haven't already — idempotent in gossipsub.
|
|
383
|
+
let justSubscribed = false;
|
|
384
|
+
if (!pubsub.getTopics().includes(topic)) {
|
|
385
|
+
pubsub.subscribe(topic);
|
|
386
|
+
justSubscribed = true;
|
|
387
|
+
freshlySubscribedTopics.add(topic);
|
|
388
|
+
}
|
|
389
|
+
// Gossipsub mesh forms on a heartbeat (~1s default). If we JUST
|
|
390
|
+
// subscribed and there are connected peers, wait one heartbeat so
|
|
391
|
+
// the peer learns we're in the topic before we try to publish.
|
|
392
|
+
// Without this, the very first publish from a fresh process
|
|
393
|
+
// reliably goes nowhere.
|
|
394
|
+
if (justSubscribed && helia.libp2p.getConnections().length > 0) {
|
|
395
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
396
|
+
}
|
|
397
|
+
const announcement = {
|
|
398
|
+
v: 1,
|
|
399
|
+
docId,
|
|
400
|
+
cid,
|
|
401
|
+
author,
|
|
402
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
403
|
+
};
|
|
404
|
+
const bytes = new TextEncoder().encode(JSON.stringify(announcement));
|
|
405
|
+
await pubsub.publish(topic, bytes);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// Best-effort; local write already persisted. Don't fail the caller.
|
|
409
|
+
// Log to stderr so operators can see sync hiccups without crashing.
|
|
410
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
411
|
+
console.error(`[brain] publish to ${topic} failed: ${err.message}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Subscribe to a doc's gossipsub topic and invoke `onAnnouncement` for
|
|
417
|
+
* every incoming head-CID announcement. Malformed payloads are logged
|
|
418
|
+
* and skipped — the sync layer is permissionless, so a subscriber must
|
|
419
|
+
* not crash on bad input from a misbehaving peer.
|
|
420
|
+
*
|
|
421
|
+
* Returns an unsubscribe function that removes the handler and the
|
|
422
|
+
* topic subscription.
|
|
423
|
+
*/
|
|
424
|
+
async function subscribeBrainTopic(docId, onAnnouncement) {
|
|
425
|
+
const helia = await initBrainNode();
|
|
426
|
+
const pubsub = helia.libp2p.services?.pubsub;
|
|
427
|
+
if (!pubsub) {
|
|
428
|
+
throw new Error('libp2p pubsub service not configured on this node');
|
|
429
|
+
}
|
|
430
|
+
const topic = topicForDoc(docId);
|
|
431
|
+
const listener = (evt) => {
|
|
432
|
+
const msg = evt.detail;
|
|
433
|
+
if (!msg || msg.topic !== topic)
|
|
434
|
+
return;
|
|
435
|
+
try {
|
|
436
|
+
const text = new TextDecoder().decode(msg.data);
|
|
437
|
+
const ann = JSON.parse(text);
|
|
438
|
+
if (ann.v !== 1 || !ann.docId || !ann.cid) {
|
|
439
|
+
throw new Error('malformed announcement: missing v/docId/cid');
|
|
440
|
+
}
|
|
441
|
+
const from = msg.from?.toString?.() || 'unknown';
|
|
442
|
+
onAnnouncement(ann, from);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
446
|
+
console.error(`[brain] bad announcement on ${topic}: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
pubsub.addEventListener('message', listener);
|
|
451
|
+
pubsub.subscribe(topic);
|
|
452
|
+
return () => {
|
|
453
|
+
try {
|
|
454
|
+
pubsub.removeEventListener('message', listener);
|
|
455
|
+
pubsub.unsubscribe(topic);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Already gone — nothing to do.
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Clean shutdown. Call before exiting a long-lived process.
|
|
464
|
+
* Safe to call when no node was initialized.
|
|
465
|
+
*/
|
|
466
|
+
async function stopBrainNode() {
|
|
467
|
+
if (!cachedNode)
|
|
468
|
+
return;
|
|
469
|
+
try {
|
|
470
|
+
await cachedNode.stop();
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
cachedNode = null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Automerge doc layer — MVP step 3
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
//
|
|
480
|
+
// Each brain document (pop.brain.shared, pop.brain.projects, etc.) lives as
|
|
481
|
+
// an Automerge doc. On write: apply change, Automerge.save() → full-state
|
|
482
|
+
// bytes, write to Helia blockstore → CID, update the local "heads manifest"
|
|
483
|
+
// mapping docId → CID. On read: look up CID in manifest, fetch bytes from
|
|
484
|
+
// blockstore, Automerge.load() → doc.
|
|
485
|
+
//
|
|
486
|
+
// This is the simplest possible model: snapshot-per-write, local-only,
|
|
487
|
+
// no sync yet. Delta propagation (via gossipsub) + cross-peer block fetch
|
|
488
|
+
// (via Bitswap) are subsequent steps. The current implementation is
|
|
489
|
+
// correct-and-boring on purpose — proves the storage + serialization layer
|
|
490
|
+
// before adding network complexity.
|
|
491
|
+
let automergeModule = null;
|
|
492
|
+
async function getAutomerge() {
|
|
493
|
+
if (!automergeModule) {
|
|
494
|
+
automergeModule = await esmImport('@automerge/automerge');
|
|
495
|
+
}
|
|
496
|
+
return automergeModule;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Path to the heads manifest — a small JSON file mapping brain doc IDs to
|
|
500
|
+
* the CID of their most recent full-state snapshot. Lives alongside the
|
|
501
|
+
* blockstore because heads are local state (each peer tracks its own view
|
|
502
|
+
* of what it has seen) while the blocks themselves are shared.
|
|
503
|
+
*/
|
|
504
|
+
function getHeadsManifestPath() {
|
|
505
|
+
return (0, path_1.join)(getBrainHome(), 'doc-heads.json');
|
|
506
|
+
}
|
|
507
|
+
function loadHeadsManifest() {
|
|
508
|
+
const p = getHeadsManifestPath();
|
|
509
|
+
if (!(0, fs_1.existsSync)(p))
|
|
510
|
+
return {};
|
|
511
|
+
try {
|
|
512
|
+
return JSON.parse((0, fs_1.readFileSync)(p, 'utf8'));
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return {};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function saveHeadsManifest(manifest) {
|
|
519
|
+
// HB#324: atomic write-tmp-then-rename. The brain daemon and short-lived
|
|
520
|
+
// CLI processes can both touch this file (daemon on incoming-merge from
|
|
521
|
+
// gossipsub, CLI on local append when no daemon is running). A plain
|
|
522
|
+
// writeFileSync has a window during which a concurrent reader would see
|
|
523
|
+
// a truncated JSON and throw. POSIX rename() is atomic on the same fs,
|
|
524
|
+
// so a reader always sees either the previous complete file or the new
|
|
525
|
+
// complete file — never a half-written one.
|
|
526
|
+
const finalPath = getHeadsManifestPath();
|
|
527
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`;
|
|
528
|
+
(0, fs_1.writeFileSync)(tmpPath, JSON.stringify(manifest, null, 2));
|
|
529
|
+
try {
|
|
530
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
531
|
+
require('fs').renameSync(tmpPath, finalPath);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
// Best-effort cleanup if the rename failed.
|
|
535
|
+
try {
|
|
536
|
+
require('fs').unlinkSync(tmpPath);
|
|
537
|
+
}
|
|
538
|
+
catch { }
|
|
539
|
+
throw err;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Load the genesis bytes for a canonical brain doc if a
|
|
544
|
+
* `<docId>.genesis.bin` file exists in the repo's
|
|
545
|
+
* `agent/brain/Knowledge/` directory.
|
|
546
|
+
*
|
|
547
|
+
* Task #352 (HB#337): genesis files are tiny (~150 bytes) binary
|
|
548
|
+
* Automerge snapshots of the empty canonical doc shape. When every
|
|
549
|
+
* agent loads from the same genesis bytes before their first write,
|
|
550
|
+
* all subsequent cross-agent writes share a common root and
|
|
551
|
+
* `Automerge.merge` correctly combines them. Without the shared
|
|
552
|
+
* genesis, independent initialization creates disjoint histories
|
|
553
|
+
* that silently drop content at merge time — see task #350 for the
|
|
554
|
+
* disjoint-history stopgap and the `retroactive-verification-finds-
|
|
555
|
+
* what-forward-tests-miss` brain lesson for the full context.
|
|
556
|
+
*
|
|
557
|
+
* Returns the raw bytes if the file exists, or null if not
|
|
558
|
+
* (falls through to `Automerge.init()` for non-canonical docs or
|
|
559
|
+
* for agents without the genesis files available).
|
|
560
|
+
*/
|
|
561
|
+
function loadGenesisBytes(docId) {
|
|
562
|
+
const genesisPath = (0, path_1.join)((0, brain_paths_1.getRepoBrainRoot)(), 'Knowledge', `${docId}.genesis.bin`);
|
|
563
|
+
if (!(0, fs_1.existsSync)(genesisPath))
|
|
564
|
+
return null;
|
|
565
|
+
try {
|
|
566
|
+
const bytes = (0, fs_1.readFileSync)(genesisPath);
|
|
567
|
+
return Uint8Array.from(bytes);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Open an Automerge document by ID. If the manifest has a head CID for
|
|
575
|
+
* this ID, loads the state from the blockstore. Otherwise returns a fresh
|
|
576
|
+
* empty doc — seeded from the canonical genesis file if one exists for
|
|
577
|
+
* this docId (task #352), or a plain `Automerge.init()` as a last resort.
|
|
578
|
+
*
|
|
579
|
+
* Returns the doc plus its current head CID (null for new docs) so the
|
|
580
|
+
* caller can tell whether this was a load or an init.
|
|
581
|
+
*/
|
|
582
|
+
async function openBrainDoc(docId) {
|
|
583
|
+
const helia = await initBrainNode();
|
|
584
|
+
const Automerge = await getAutomerge();
|
|
585
|
+
const manifest = loadHeadsManifest();
|
|
586
|
+
const headCidStr = manifest[docId];
|
|
587
|
+
if (!headCidStr) {
|
|
588
|
+
// Task #352: shared-genesis bootstrap. If the repo has a canonical
|
|
589
|
+
// `<docId>.genesis.bin` file, load from it so every agent's first
|
|
590
|
+
// write builds on the same root doc. Without this, independent
|
|
591
|
+
// `Automerge.init()` calls produce disjoint histories that silently
|
|
592
|
+
// drop content at merge time.
|
|
593
|
+
const genesisBytes = loadGenesisBytes(docId);
|
|
594
|
+
if (genesisBytes) {
|
|
595
|
+
try {
|
|
596
|
+
const doc = Automerge.load(genesisBytes);
|
|
597
|
+
return { doc, headCid: null };
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
// Genesis file corrupt or incompatible — fall through to init().
|
|
601
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
602
|
+
console.error(`[brain] failed to load genesis for ${docId}: ${err.message}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { doc: Automerge.init(), headCid: null };
|
|
607
|
+
}
|
|
608
|
+
// Parse the CID via multiformats and read the block via blockstore-fs
|
|
609
|
+
// (helia.blockstore.get has a version-skew issue through the typed
|
|
610
|
+
// wrapper; going directly through FsBlockstore is the stable path).
|
|
611
|
+
const { CID } = await esmImport('multiformats/cid');
|
|
612
|
+
const { FsBlockstore } = await esmImport('blockstore-fs');
|
|
613
|
+
const bs = new FsBlockstore((0, path_1.join)(getBrainHome(), 'helia-blocks'));
|
|
614
|
+
await bs.open();
|
|
615
|
+
try {
|
|
616
|
+
const cid = CID.parse(headCidStr);
|
|
617
|
+
const envelopeBytes = await bs.get(cid);
|
|
618
|
+
// Step 4: block is now a signed envelope, not raw Automerge bytes.
|
|
619
|
+
// Unwrap, verify, check allowlist, then load the inner Automerge.
|
|
620
|
+
const envelope = JSON.parse(new TextDecoder().decode(envelopeBytes));
|
|
621
|
+
const author = (0, brain_signing_1.verifyBrainChange)(envelope);
|
|
622
|
+
const authz = await (0, brain_signing_1.isAuthorizedAuthor)(author);
|
|
623
|
+
if (!authz.allowed) {
|
|
624
|
+
throw new Error(`Brain doc "${docId}" head is signed by ${author}, not authorized. ` +
|
|
625
|
+
`${authz.fallbackReason}. ` +
|
|
626
|
+
`Either vouch this address into the Argus member hat, or add it to ` +
|
|
627
|
+
`agent/brain/Config/brain-allowlist.json for an emergency override.`);
|
|
628
|
+
}
|
|
629
|
+
if (authz.mode === 'static-fallback' && authz.fallbackReason) {
|
|
630
|
+
// Surface the fallback path so operators can see when dynamic is down.
|
|
631
|
+
console.error(`[brain] ${authz.fallbackReason}`);
|
|
632
|
+
}
|
|
633
|
+
const automergeBytes = (0, brain_signing_1.unwrapAutomergeBytes)(envelope);
|
|
634
|
+
const doc = Automerge.load(automergeBytes);
|
|
635
|
+
return { doc, headCid: headCidStr };
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
await bs.close();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Apply a change function to a brain doc and persist the new state.
|
|
643
|
+
*
|
|
644
|
+
* Writes the new full-state snapshot as a raw IPLD block, computes its
|
|
645
|
+
* CID, updates the heads manifest. Returns the new CID.
|
|
646
|
+
*
|
|
647
|
+
* Snapshot-per-write is simpler than delta-based persistence for MVP;
|
|
648
|
+
* the tradeoff is that Automerge.save() produces the full state each
|
|
649
|
+
* time. For the shared.md / projects.md scale (KB-range docs) this is
|
|
650
|
+
* fine. When docs grow, switch to saving incremental changes via
|
|
651
|
+
* Automerge.getChanges() and a linked-list of block CIDs.
|
|
652
|
+
*/
|
|
653
|
+
async function applyBrainChange(docId, changeFn, options) {
|
|
654
|
+
const { doc: oldDoc } = await openBrainDoc(docId);
|
|
655
|
+
const Automerge = await getAutomerge();
|
|
656
|
+
const newDoc = Automerge.change(oldDoc, changeFn);
|
|
657
|
+
// Task #346 (HB#168): write-time schema validation.
|
|
658
|
+
// Validate pre-change and post-change. Only reject regressions —
|
|
659
|
+
// valid → invalid transitions. If the doc was already invalid before
|
|
660
|
+
// this change, the bad state was inherited (historical, pre-enforcement
|
|
661
|
+
// write), and this write is allowed through so existing docs remain
|
|
662
|
+
// usable. This preserves the task constraint "existing 30 lessons
|
|
663
|
+
// must not be retroactively rejected."
|
|
664
|
+
if (!options?.allowInvalidShape) {
|
|
665
|
+
const { validateBrainDocShape } = await Promise.resolve().then(() => __importStar(require('./brain-schemas')));
|
|
666
|
+
const preResult = validateBrainDocShape(docId, oldDoc);
|
|
667
|
+
const postResult = validateBrainDocShape(docId, newDoc);
|
|
668
|
+
if (preResult.ok && !postResult.ok) {
|
|
669
|
+
throw new Error(`Brain write rejected: schema validation failed for ${docId}\n` +
|
|
670
|
+
postResult.errors.map((e) => ` - ${e}`).join('\n') +
|
|
671
|
+
`\n\nPre-change doc was valid; this change introduces invalid shape(s). ` +
|
|
672
|
+
`Fix the CLI call OR pass --allow-invalid-shape to bypass (strongly discouraged).`);
|
|
673
|
+
}
|
|
674
|
+
// If post is still invalid but pre was also invalid, log a warning
|
|
675
|
+
// and allow through. If pre invalid and post valid, the write is a
|
|
676
|
+
// partial fix — also allow.
|
|
677
|
+
if (!preResult.ok && !postResult.ok) {
|
|
678
|
+
// Silent — inherited bad state, not this write's fault.
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const automergeBytes = Automerge.save(newDoc);
|
|
682
|
+
// Step 4: wrap the snapshot in a signed envelope before persisting.
|
|
683
|
+
// The envelope is what becomes the IPLD block; CID is over the
|
|
684
|
+
// envelope (not the raw Automerge), so sig + data are content-addressed
|
|
685
|
+
// together and can't be separated.
|
|
686
|
+
const envelope = await (0, brain_signing_1.signBrainChange)(automergeBytes);
|
|
687
|
+
const envelopeBytes = new TextEncoder().encode(JSON.stringify(envelope));
|
|
688
|
+
const { CID } = await esmImport('multiformats/cid');
|
|
689
|
+
const { sha256 } = await esmImport('multiformats/hashes/sha2');
|
|
690
|
+
const { FsBlockstore } = await esmImport('blockstore-fs');
|
|
691
|
+
const hash = await sha256.digest(envelopeBytes);
|
|
692
|
+
const cid = CID.createV1(0x55, hash);
|
|
693
|
+
const bs = new FsBlockstore((0, path_1.join)(getBrainHome(), 'helia-blocks'));
|
|
694
|
+
await bs.open();
|
|
695
|
+
try {
|
|
696
|
+
await bs.put(cid, envelopeBytes);
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
await bs.close();
|
|
700
|
+
}
|
|
701
|
+
const manifest = loadHeadsManifest();
|
|
702
|
+
manifest[docId] = cid.toString();
|
|
703
|
+
saveHeadsManifest(manifest);
|
|
704
|
+
// Step 5: broadcast the new head CID on the doc's gossipsub topic.
|
|
705
|
+
// Best-effort — if there are no peers or publish fails, the local
|
|
706
|
+
// write has already persisted and missed announcements recover at
|
|
707
|
+
// next peer reconnect via delta fetch. We do NOT await errors here
|
|
708
|
+
// because the caller's contract is "change was persisted locally."
|
|
709
|
+
try {
|
|
710
|
+
await publishBrainHead(docId, cid.toString(), envelope.author);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// publishBrainHead already swallows errors; belt-and-suspenders.
|
|
714
|
+
}
|
|
715
|
+
return { headCid: cid.toString(), doc: newDoc, author: envelope.author };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Import a raw Automerge snapshot as the new local head for a brain doc.
|
|
719
|
+
*
|
|
720
|
+
* Task #353 (HB#348): the post-HB#352 follow-up for migrating the 3 existing
|
|
721
|
+
* Argus agents off their pre-genesis disjoint Automerge state. The HB#341
|
|
722
|
+
* export step pinned argus's current state for all 3 canonical docs to IPFS
|
|
723
|
+
* (Qm...). This function is the receive side: load those bytes into vigil_01
|
|
724
|
+
* or sentinel_01's brain home as the new shared head so their subsequent
|
|
725
|
+
* writes build on argus's root instead of their own disjoint root.
|
|
726
|
+
*
|
|
727
|
+
* ## Semantics
|
|
728
|
+
*
|
|
729
|
+
* - Load the bytes via `Automerge.load()` to validate structural integrity.
|
|
730
|
+
* Throws if the bytes are corrupt or not an Automerge snapshot.
|
|
731
|
+
* - Run the standard write-time schema validator (#346) unless
|
|
732
|
+
* `opts.allowInvalidShape` is set.
|
|
733
|
+
* - Sign a new envelope via `signBrainChange` — the importing agent becomes
|
|
734
|
+
* the envelope author for the new head, even though the Automerge content
|
|
735
|
+
* is preserved from the source.
|
|
736
|
+
* - Write the envelope as a new IPLD block, update the manifest, publish the
|
|
737
|
+
* head CID via gossipsub (same as applyBrainChange's persist+publish flow).
|
|
738
|
+
*
|
|
739
|
+
* ## Safety
|
|
740
|
+
*
|
|
741
|
+
* This function REPLACES the local head. If the local brain home already
|
|
742
|
+
* has content for this docId, that state becomes orphaned (the old envelope
|
|
743
|
+
* stays in the blockstore, but the manifest no longer points at it). Callers
|
|
744
|
+
* must decide whether to preserve local-only content before calling:
|
|
745
|
+
*
|
|
746
|
+
* 1. `pop brain read --doc <id> --json` to snapshot local state
|
|
747
|
+
* 2. `pop brain import-snapshot --doc <id> --file <canonical-bytes>`
|
|
748
|
+
* 3. Replay local-only lessons via `pop brain append-lesson` calls on the
|
|
749
|
+
* new shared baseline
|
|
750
|
+
*
|
|
751
|
+
* The CLI wrapper (`pop brain import-snapshot`) enforces a `--force` flag
|
|
752
|
+
* requirement when a local head exists, so there are no accidental replaces.
|
|
753
|
+
*/
|
|
754
|
+
async function importBrainDoc(docId, automergeBytes, opts) {
|
|
755
|
+
// Ensure helia is initialized (same as applyBrainChange — this is a
|
|
756
|
+
// write path that needs the libp2p publish hook).
|
|
757
|
+
await initBrainNode();
|
|
758
|
+
const Automerge = await getAutomerge();
|
|
759
|
+
// Validate by loading. Throws if bytes are corrupt or not a valid
|
|
760
|
+
// Automerge snapshot.
|
|
761
|
+
const doc = Automerge.load(automergeBytes);
|
|
762
|
+
// Schema validation (same pipeline as applyBrainChange post-#346).
|
|
763
|
+
if (!opts?.allowInvalidShape) {
|
|
764
|
+
const { validateBrainDocShape } = await Promise.resolve().then(() => __importStar(require('./brain-schemas')));
|
|
765
|
+
const result = validateBrainDocShape(docId, doc);
|
|
766
|
+
if (!result.ok) {
|
|
767
|
+
throw new Error(`Imported snapshot fails schema validation for ${docId}:\n` +
|
|
768
|
+
result.errors.map((e) => ` - ${e}`).join('\n') +
|
|
769
|
+
`\n\nPass --allow-invalid-shape to bypass (strongly discouraged).`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// Sign a NEW envelope with the imported bytes. The envelope author is
|
|
773
|
+
// the importing agent (from POP_PRIVATE_KEY), not the source agent.
|
|
774
|
+
// That's correct: the importer is vouching for the import, and the
|
|
775
|
+
// Automerge content carries the history of whoever wrote it.
|
|
776
|
+
const envelope = await (0, brain_signing_1.signBrainChange)(automergeBytes);
|
|
777
|
+
const envelopeBytes = new TextEncoder().encode(JSON.stringify(envelope));
|
|
778
|
+
// Persist + publish — same flow as applyBrainChange.
|
|
779
|
+
const { CID } = await esmImport('multiformats/cid');
|
|
780
|
+
const { sha256 } = await esmImport('multiformats/hashes/sha2');
|
|
781
|
+
const { FsBlockstore } = await esmImport('blockstore-fs');
|
|
782
|
+
const hash = await sha256.digest(envelopeBytes);
|
|
783
|
+
const cid = CID.createV1(0x55, hash);
|
|
784
|
+
const bs = new FsBlockstore((0, path_1.join)(getBrainHome(), 'helia-blocks'));
|
|
785
|
+
await bs.open();
|
|
786
|
+
try {
|
|
787
|
+
await bs.put(cid, envelopeBytes);
|
|
788
|
+
}
|
|
789
|
+
finally {
|
|
790
|
+
await bs.close();
|
|
791
|
+
}
|
|
792
|
+
const manifest = loadHeadsManifest();
|
|
793
|
+
manifest[docId] = cid.toString();
|
|
794
|
+
saveHeadsManifest(manifest);
|
|
795
|
+
// Publish the new head via gossipsub. Best-effort — local write has
|
|
796
|
+
// already persisted, and missed announcements recover at next peer
|
|
797
|
+
// reconnect via the usual rebroadcast loop.
|
|
798
|
+
try {
|
|
799
|
+
await publishBrainHead(docId, cid.toString(), envelope.author);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// publishBrainHead already swallows errors; belt-and-suspenders.
|
|
803
|
+
}
|
|
804
|
+
return { headCid: cid.toString(), doc, author: envelope.author };
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* List all known brain doc IDs + their current head CIDs.
|
|
808
|
+
* Reads the manifest file directly — doesn't require Helia to be running.
|
|
809
|
+
*/
|
|
810
|
+
function listBrainDocs() {
|
|
811
|
+
const manifest = loadHeadsManifest();
|
|
812
|
+
return Object.entries(manifest).map(([docId, headCid]) => ({ docId, headCid }));
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Given a remote head-CID announcement, fetch the block (via Bitswap if
|
|
816
|
+
* not already local), verify it, merge with the local doc, and update
|
|
817
|
+
* the manifest. Returns the action taken.
|
|
818
|
+
*
|
|
819
|
+
* Does NOT re-publish after merging. The next local write (via
|
|
820
|
+
* applyBrainChange) will publish from the merged state. This avoids
|
|
821
|
+
* gossip ping-pong where two peers would keep producing "merge heads"
|
|
822
|
+
* and broadcasting them at each other.
|
|
823
|
+
*/
|
|
824
|
+
async function fetchAndMergeRemoteHead(docId, remoteCidStr) {
|
|
825
|
+
// Cheap dedup: we already track this exact CID, nothing to do.
|
|
826
|
+
const manifest = loadHeadsManifest();
|
|
827
|
+
if (manifest[docId] === remoteCidStr) {
|
|
828
|
+
return { action: 'skip', reason: 'already at this head', headCid: remoteCidStr };
|
|
829
|
+
}
|
|
830
|
+
const helia = await initBrainNode();
|
|
831
|
+
const Automerge = await getAutomerge();
|
|
832
|
+
const { CID } = await esmImport('multiformats/cid');
|
|
833
|
+
const { sha256 } = await esmImport('multiformats/hashes/sha2');
|
|
834
|
+
const { FsBlockstore } = await esmImport('blockstore-fs');
|
|
835
|
+
const remoteCid = CID.parse(remoteCidStr);
|
|
836
|
+
// Fetch the block. helia.blockstore.get transparently goes to Bitswap
|
|
837
|
+
// if the block isn't already local. With a small session timeout so
|
|
838
|
+
// we don't hang forever on a bad announcement.
|
|
839
|
+
let envelopeBytes;
|
|
840
|
+
try {
|
|
841
|
+
// helia 5.x: blockstore.get(cid) returns Promise<Uint8Array>.
|
|
842
|
+
// helia 6.x: blockstore.get(cid) returns AsyncGenerator<Uint8Array>.
|
|
843
|
+
// Handle both shapes defensively.
|
|
844
|
+
const result = await helia.blockstore.get(remoteCid);
|
|
845
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
846
|
+
const util = await esmImport('util');
|
|
847
|
+
console.error('[brain] blockstore.get returned:', util.inspect(result, { depth: 1, maxArrayLength: 4 }));
|
|
848
|
+
}
|
|
849
|
+
if (result instanceof Uint8Array) {
|
|
850
|
+
envelopeBytes = result;
|
|
851
|
+
}
|
|
852
|
+
else if (result && typeof result[Symbol.asyncIterator] === 'function') {
|
|
853
|
+
const chunks = [];
|
|
854
|
+
let total = 0;
|
|
855
|
+
for await (const chunk of result) {
|
|
856
|
+
chunks.push(chunk);
|
|
857
|
+
total += chunk.byteLength;
|
|
858
|
+
}
|
|
859
|
+
const merged = new Uint8Array(total);
|
|
860
|
+
let offset = 0;
|
|
861
|
+
for (const c of chunks) {
|
|
862
|
+
merged.set(c, offset);
|
|
863
|
+
offset += c.byteLength;
|
|
864
|
+
}
|
|
865
|
+
envelopeBytes = merged;
|
|
866
|
+
}
|
|
867
|
+
else if (result && typeof result.slice === 'function') {
|
|
868
|
+
// Uint8ArrayList path.
|
|
869
|
+
envelopeBytes = result.slice();
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
envelopeBytes = result;
|
|
873
|
+
}
|
|
874
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
875
|
+
const util = await esmImport('util');
|
|
876
|
+
console.error('[brain] blockstore.get returned:', util.inspect(envelopeBytes, { depth: 2, maxArrayLength: 8 }));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
return {
|
|
881
|
+
action: 'reject',
|
|
882
|
+
reason: `bitswap fetch failed for ${remoteCidStr}: ${err.message}`,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
// Parse + verify envelope + allowlist check. Any failure => reject.
|
|
886
|
+
// helia.blockstore.get may return a Uint8ArrayList (from the
|
|
887
|
+
// `uint8arraylist` package) which is NOT a Uint8Array and which
|
|
888
|
+
// .subarray() returns the first chunk only. Use .slice() to
|
|
889
|
+
// materialize a contiguous Uint8Array covering the full payload.
|
|
890
|
+
let remoteEnvelope;
|
|
891
|
+
try {
|
|
892
|
+
let plain;
|
|
893
|
+
if (envelopeBytes instanceof Uint8Array) {
|
|
894
|
+
plain = envelopeBytes;
|
|
895
|
+
}
|
|
896
|
+
else if (typeof envelopeBytes.slice === 'function') {
|
|
897
|
+
// Uint8ArrayList.slice() returns a contiguous Uint8Array of the full list.
|
|
898
|
+
plain = envelopeBytes.slice();
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
plain = Uint8Array.from(envelopeBytes);
|
|
902
|
+
}
|
|
903
|
+
remoteEnvelope = JSON.parse(new TextDecoder().decode(plain));
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
return { action: 'reject', reason: `envelope parse failed: ${err.message}` };
|
|
907
|
+
}
|
|
908
|
+
let author;
|
|
909
|
+
try {
|
|
910
|
+
author = (0, brain_signing_1.verifyBrainChange)(remoteEnvelope);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
return { action: 'reject', reason: `signature verify failed: ${err.message}` };
|
|
914
|
+
}
|
|
915
|
+
const authz = await (0, brain_signing_1.isAuthorizedAuthor)(author);
|
|
916
|
+
if (!authz.allowed) {
|
|
917
|
+
return {
|
|
918
|
+
action: 'reject',
|
|
919
|
+
reason: `author ${author} not authorized (${authz.fallbackReason}) — block stored but manifest NOT updated`,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
if (authz.mode === 'static-fallback' && authz.fallbackReason) {
|
|
923
|
+
console.error(`[brain] ${authz.fallbackReason}`);
|
|
924
|
+
}
|
|
925
|
+
const remoteAutomergeBytes = (0, brain_signing_1.unwrapAutomergeBytes)(remoteEnvelope);
|
|
926
|
+
const remoteDoc = Automerge.load(remoteAutomergeBytes);
|
|
927
|
+
// Case A: we have no local head for this doc — just adopt remote.
|
|
928
|
+
// The block is already in our blockstore thanks to Bitswap's side
|
|
929
|
+
// effect, so we only need to update the manifest.
|
|
930
|
+
if (!manifest[docId]) {
|
|
931
|
+
manifest[docId] = remoteCidStr;
|
|
932
|
+
saveHeadsManifest(manifest);
|
|
933
|
+
return {
|
|
934
|
+
action: 'adopt',
|
|
935
|
+
reason: 'no local head — adopting remote directly',
|
|
936
|
+
headCid: remoteCidStr,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
// Case B: we have a local head, load it and merge.
|
|
940
|
+
const { doc: localDoc } = await openBrainDoc(docId);
|
|
941
|
+
// Task #350 (HB#335): detect disjoint Automerge histories before
|
|
942
|
+
// attempting the merge. Automerge.merge() and Automerge.applyChanges()
|
|
943
|
+
// BOTH silently drop remote content when the two docs don't share a
|
|
944
|
+
// common fork ancestor — verified empirically in HB#335 dogfood. This
|
|
945
|
+
// is a fundamental property of Automerge: docs must share a root
|
|
946
|
+
// initialized via the same from()/init() call for cross-doc operations
|
|
947
|
+
// to work. The detection here refuses the merge with a clear error
|
|
948
|
+
// and leaves the local manifest unchanged. The block stays in the
|
|
949
|
+
// blockstore for post-mortem inspection.
|
|
950
|
+
//
|
|
951
|
+
// Detection: if local and remote both have changes and zero change
|
|
952
|
+
// hashes overlap, they have disjoint histories.
|
|
953
|
+
try {
|
|
954
|
+
const localChanges = Automerge.getAllChanges(localDoc);
|
|
955
|
+
const remoteChanges = Automerge.getAllChanges(remoteDoc);
|
|
956
|
+
if (localChanges.length > 0 && remoteChanges.length > 0) {
|
|
957
|
+
// Automerge change objects have a .hash field in dev builds, but
|
|
958
|
+
// the binary serialized form also carries it. The canonical way
|
|
959
|
+
// to extract hashes is via Automerge.decodeChange.
|
|
960
|
+
const localHashes = new Set();
|
|
961
|
+
for (const c of localChanges) {
|
|
962
|
+
const decoded = Automerge.decodeChange(c);
|
|
963
|
+
localHashes.add(decoded.hash);
|
|
964
|
+
}
|
|
965
|
+
let overlap = false;
|
|
966
|
+
for (const c of remoteChanges) {
|
|
967
|
+
const decoded = Automerge.decodeChange(c);
|
|
968
|
+
if (localHashes.has(decoded.hash)) {
|
|
969
|
+
overlap = true;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (!overlap) {
|
|
974
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
975
|
+
console.error(`[brain] disjoint-history detected for doc="${docId}" — ` +
|
|
976
|
+
`local has ${localChanges.length} changes, remote has ${remoteChanges.length} changes, ` +
|
|
977
|
+
`zero overlap. Refusing merge to prevent silent data loss (task #350).`);
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
action: 'reject',
|
|
981
|
+
reason: `disjoint Automerge histories (local ${localChanges.length} changes, remote ${remoteChanges.length} changes, zero overlap) — ` +
|
|
982
|
+
`both docs were independently initialized. Automerge requires shared-root docs for cross-doc merge; the remote block is stored but the manifest is unchanged to prevent silent data loss. ` +
|
|
983
|
+
`Workaround: bootstrap the other agent's brain home from the committed agent/brain/Knowledge/${docId}.generated.md via \`pop brain migrate\` before their first write. See task #350 for the shared-genesis fix.`,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch (err) {
|
|
989
|
+
// If the disjoint-history detection itself fails (e.g. Automerge API
|
|
990
|
+
// change), log and fall through to the merge attempt. Better to
|
|
991
|
+
// possibly-drop than to definitely-fail.
|
|
992
|
+
if (process.env.POP_BRAIN_DEBUG) {
|
|
993
|
+
console.error(`[brain] disjoint-history check failed: ${err?.message ?? err}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const mergedDoc = Automerge.merge(localDoc, remoteDoc);
|
|
997
|
+
// Decide what to do with the merge. Compare Automerge heads rather
|
|
998
|
+
// than raw bytes — snapshots of equivalent state can serialize to
|
|
999
|
+
// different bytes because Automerge preserves per-actor change log.
|
|
1000
|
+
const localHeads = Automerge.getHeads(localDoc).sort();
|
|
1001
|
+
const remoteHeads = Automerge.getHeads(remoteDoc).sort();
|
|
1002
|
+
const mergedHeads = Automerge.getHeads(mergedDoc).sort();
|
|
1003
|
+
const sameArray = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1004
|
+
// Merged == local: remote was a strict ancestor (or empty). Nothing to do.
|
|
1005
|
+
if (sameArray(mergedHeads, localHeads)) {
|
|
1006
|
+
return {
|
|
1007
|
+
action: 'skip',
|
|
1008
|
+
reason: 'local doc is ahead of remote (remote is an ancestor)',
|
|
1009
|
+
headCid: manifest[docId],
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
// Merged == remote: local was a strict ancestor. Adopt remote CID.
|
|
1013
|
+
if (sameArray(mergedHeads, remoteHeads)) {
|
|
1014
|
+
manifest[docId] = remoteCidStr;
|
|
1015
|
+
saveHeadsManifest(manifest);
|
|
1016
|
+
return {
|
|
1017
|
+
action: 'adopt',
|
|
1018
|
+
reason: 'remote is ahead of local — fast-forwarding',
|
|
1019
|
+
headCid: remoteCidStr,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
// Merged is a true merge — both sides had unique changes. Serialize
|
|
1023
|
+
// the merged doc, sign it with OUR key, write as a new block, update
|
|
1024
|
+
// the manifest. We intentionally DO NOT publish here to avoid a
|
|
1025
|
+
// gossip cycle with a peer doing the same merge; the next local
|
|
1026
|
+
// applyBrainChange will broadcast this state forward.
|
|
1027
|
+
const mergedBytes = Automerge.save(mergedDoc);
|
|
1028
|
+
const mergeEnvelope = await (0, brain_signing_1.signBrainChange)(mergedBytes);
|
|
1029
|
+
const mergeEnvelopeBytes = new TextEncoder().encode(JSON.stringify(mergeEnvelope));
|
|
1030
|
+
const hash = await sha256.digest(mergeEnvelopeBytes);
|
|
1031
|
+
const mergeCid = CID.createV1(0x55, hash);
|
|
1032
|
+
const bs = new FsBlockstore((0, path_1.join)(getBrainHome(), 'helia-blocks'));
|
|
1033
|
+
await bs.open();
|
|
1034
|
+
try {
|
|
1035
|
+
await bs.put(mergeCid, mergeEnvelopeBytes);
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
await bs.close();
|
|
1039
|
+
}
|
|
1040
|
+
manifest[docId] = mergeCid.toString();
|
|
1041
|
+
saveHeadsManifest(manifest);
|
|
1042
|
+
return {
|
|
1043
|
+
action: 'merge',
|
|
1044
|
+
reason: `CRDT merge of local ${localHeads.length}-head with remote ${remoteHeads.length}-head into ${mergedHeads.length}-head`,
|
|
1045
|
+
headCid: mergeCid.toString(),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Projection helper — returns the current Automerge doc as a plain
|
|
1050
|
+
* JS object (for JSON display). Wraps openBrainDoc and Automerge.clone.
|
|
1051
|
+
*/
|
|
1052
|
+
async function readBrainDoc(docId) {
|
|
1053
|
+
const { doc, headCid } = await openBrainDoc(docId);
|
|
1054
|
+
const Automerge = await getAutomerge();
|
|
1055
|
+
// Automerge docs are frozen proxies; return a plain JS snapshot.
|
|
1056
|
+
return { doc: Automerge.toJS(doc), headCid };
|
|
1057
|
+
}
|