@cryptolibertus/pi-peer 0.3.2

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.
@@ -0,0 +1,356 @@
1
+ import { mkdir, open, readFile as defaultReadFile } from "node:fs/promises";
2
+ import { hostname } from "node:os";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+
5
+ import { normalizePeerDescriptor } from "./comms.mjs";
6
+ import { PEER_VERSION, peerProtocolMetadata, redactPeerAuditValue } from "./protocol.mjs";
7
+
8
+ export const PEER_SETTINGS_RELATIVE_PATH = ".pi/settings.json";
9
+ export const PEER_CONFIG_RELATIVE_PATH = ".pi/peers.json";
10
+ export const PI_PEER_ID_ENV = "PI_PEER_ID";
11
+ export const SUPPORTED_PEER_TRANSPORTS = Object.freeze(["coms"]);
12
+ export const DEFAULT_AGENT_MD_MAX_BYTES = 24 * 1024;
13
+
14
+ const SUPPORTED_PEER_TRANSPORT_SET = new Set(SUPPORTED_PEER_TRANSPORTS);
15
+
16
+ export async function loadPeerRuntimeConfig(cwd, options = {}) {
17
+ const readFile = options.readFile || defaultReadFile;
18
+ const [settings, peerFile] = await Promise.all([
19
+ readJsonMaybe(resolve(cwd, PEER_SETTINGS_RELATIVE_PATH), readFile),
20
+ readJsonMaybe(resolve(cwd, PEER_CONFIG_RELATIVE_PATH), readFile),
21
+ ]);
22
+ return parsePeerRuntimeConfig({ settings, peerFile, env: options.env || process.env });
23
+ }
24
+
25
+ export function parsePeerRuntimeConfig({ settings, peerFile, env } = {}) {
26
+ const hasSettings = isPlainObject(settings);
27
+ const hasPeerFile = isPlainObject(peerFile);
28
+ const warnings = [];
29
+ const enabled = settingsEnabled(settings) || peerFile?.enabled === true;
30
+ const peersById = new Map();
31
+
32
+ for (const peer of configuredPeers(settings?.peers, "settings", warnings)) peersById.set(peer.peerId, peer);
33
+ for (const peer of configuredPeers(peerFile?.peers, "peers", warnings)) peersById.set(peer.peerId, { ...(peersById.get(peer.peerId) || {}), ...peer });
34
+
35
+ const manifest = normalizePeerManifest(peerFile?.manifest || settings?.peerMessaging?.manifest || settings?.manifest);
36
+ const peers = [...peersById.values()].map((peer) => markUnsupportedTransport(normalizePeerDescriptor({ ...manifestDefaults(manifest), ...peer }), warnings));
37
+ const peerFileLocalPeerId = normalizePeerId(peerFile?.localPeerId);
38
+ const settingsPeerMessagingLocalPeerId = normalizePeerId(settings?.peerMessaging?.localPeerId);
39
+ const settingsLocalPeerId = normalizePeerId(settings?.localPeerId);
40
+ const localPeerId = peerFileLocalPeerId || settingsPeerMessagingLocalPeerId || settingsLocalPeerId;
41
+ const config = {
42
+ enabled,
43
+ source: configSource(hasSettings, hasPeerFile),
44
+ manifest,
45
+ localPeerId,
46
+ localPeerIdSource: localPeerIdSource({ peerFileLocalPeerId, settingsPeerMessagingLocalPeerId, settingsLocalPeerId }),
47
+ peers,
48
+ warnings: unique(warnings),
49
+ };
50
+ return applyLocalPeerIdOverride(config, { env });
51
+ }
52
+
53
+ export function applyLocalPeerIdOverride(config = {}, options = {}) {
54
+ const explicitLocalPeerId = normalizePeerId(options.localPeerId);
55
+ if (explicitLocalPeerId) return { ...config, localPeerId: explicitLocalPeerId, localPeerIdSource: "options.localPeerId" };
56
+
57
+ const envLocalPeerId = normalizePeerId(options.env?.[PI_PEER_ID_ENV]);
58
+ if (envLocalPeerId) return { ...config, localPeerId: envLocalPeerId, localPeerIdSource: PI_PEER_ID_ENV };
59
+
60
+ const localPeerId = normalizePeerId(config.localPeerId);
61
+ return { ...config, localPeerId, localPeerIdSource: localPeerId ? config.localPeerIdSource : undefined };
62
+ }
63
+
64
+ export async function initPeerConfig(cwd, options = {}) {
65
+ const relativePath = options.relativePath || PEER_CONFIG_RELATIVE_PATH;
66
+ const path = resolve(cwd, relativePath);
67
+ const config = buildDefaultPeerConfig(options);
68
+ await mkdir(dirname(path), { recursive: true });
69
+ let handle;
70
+ try {
71
+ handle = await open(path, "wx");
72
+ await handle.writeFile(`${JSON.stringify(config, null, 2)}\n`, "utf8");
73
+ return { ok: true, created: true, existed: false, path, relativePath, config };
74
+ } catch (error) {
75
+ if (error?.code === "EEXIST") return { ok: true, created: false, existed: true, path, relativePath };
76
+ throw error;
77
+ } finally {
78
+ await handle?.close().catch(() => {});
79
+ }
80
+ }
81
+
82
+ export function buildDefaultPeerConfig(options = {}) {
83
+ return {
84
+ enabled: options.enabled !== false,
85
+ localPeerId: normalizePeerId(options.localPeerId) || defaultLocalPeerId(),
86
+ manifest: normalizePeerManifest({
87
+ trust: options.trust || "conversation",
88
+ capabilities: options.capabilities || { intents: ["ask", "review", "notify", "coordinate", "task"] },
89
+ protocolVersion: PEER_VERSION,
90
+ }),
91
+ peers: buildDefaultPeerEntries(options),
92
+ };
93
+ }
94
+
95
+ export function summarizePeerRuntimeConfig(config) {
96
+ return {
97
+ enabled: config.enabled === true,
98
+ source: config.source || "none",
99
+ localPeerId: config.localPeerId,
100
+ localPeerIdSource: config.localPeerIdSource,
101
+ localPeerProfile: summarizePeerProfile(config.localPeerProfile),
102
+ protocolVersion: config.manifest?.protocolVersion || PEER_VERSION,
103
+ manifest: summarizePeerManifest(config.manifest),
104
+ peerCount: Array.isArray(config.peers) ? config.peers.length : 0,
105
+ peers: (config.peers || []).map((peer) => ({
106
+ peerId: peer.peerId,
107
+ transport: peer.transport,
108
+ trust: peer.trust,
109
+ status: peer.status,
110
+ maxHopCount: peer.maxHopCount,
111
+ protocolVersion: peer.protocolVersion,
112
+ compatible: peer.compatible,
113
+ capabilities: peer.capabilities || {},
114
+ ...summarizePeerProfile(peer),
115
+ ...(peer.unsupportedReason ? { unsupportedReason: peer.unsupportedReason } : {}),
116
+ })),
117
+ warnings: [...(config.warnings || [])],
118
+ };
119
+ }
120
+
121
+ export async function loadLocalPeerProfile(cwd, config = {}, options = {}) {
122
+ const readFile = options.readFile || defaultReadFile;
123
+ const profile = deriveLocalPeerProfile(config, options);
124
+ const warnings = [];
125
+ if (!profile.agentMd) return { profile, warnings };
126
+
127
+ const resolved = resolveProjectRelativeFile(cwd, profile.agentMd);
128
+ if (!resolved.ok) {
129
+ warnings.push(`${profile.peerId || "local peer"} agentMd ignored: ${resolved.reason}`);
130
+ return { profile, warnings };
131
+ }
132
+
133
+ try {
134
+ const content = await readFile(resolved.path, "utf8");
135
+ return {
136
+ profile: {
137
+ ...profile,
138
+ agentMdPath: profile.agentMd,
139
+ agentMdContent: content.slice(0, Number.isInteger(options.maxAgentMdBytes) ? options.maxAgentMdBytes : DEFAULT_AGENT_MD_MAX_BYTES),
140
+ },
141
+ warnings,
142
+ };
143
+ } catch (error) {
144
+ if (error?.code === "ENOENT") warnings.push(`${profile.peerId || "local peer"} agentMd ignored: ${profile.agentMd} was not found`);
145
+ else warnings.push(`${profile.peerId || "local peer"} agentMd ignored: ${error.message}`);
146
+ return { profile, warnings };
147
+ }
148
+ }
149
+
150
+ export function deriveLocalPeerProfile(config = {}, options = {}) {
151
+ const localPeerId = normalizePeerId(options.localPeerId) || normalizePeerId(config.localPeerId) || config.localPeerId;
152
+ const configured = findConfiguredLocalPeer(config.peers, localPeerId);
153
+ return normalizePeerProfile({ peerId: localPeerId, ...(configured || {}), ...explicitPeerProfileOptions(options) });
154
+ }
155
+
156
+ export function summarizePeerProfile(profile = {}) {
157
+ const summary = {};
158
+ for (const field of ["role", "persona"]) {
159
+ const value = safeSummaryString(profile[field]);
160
+ if (value) summary[field] = value;
161
+ }
162
+ return Object.keys(summary).length ? summary : undefined;
163
+ }
164
+
165
+ function defaultLocalPeerId() {
166
+ return `pi-${sanitizePeerId(hostname()) || "local"}`;
167
+ }
168
+
169
+ export function normalizePeerId(value) {
170
+ return typeof value === "string" && value.trim() ? sanitizePeerId(value) : undefined;
171
+ }
172
+
173
+ function localPeerIdSource({ peerFileLocalPeerId, settingsPeerMessagingLocalPeerId, settingsLocalPeerId }) {
174
+ if (peerFileLocalPeerId) return `${PEER_CONFIG_RELATIVE_PATH}:localPeerId`;
175
+ if (settingsPeerMessagingLocalPeerId) return `${PEER_SETTINGS_RELATIVE_PATH}:peerMessaging.localPeerId`;
176
+ if (settingsLocalPeerId) return `${PEER_SETTINGS_RELATIVE_PATH}:localPeerId`;
177
+ return undefined;
178
+ }
179
+
180
+ function sanitizePeerId(value) {
181
+ return String(value || "")
182
+ .trim()
183
+ .toLowerCase()
184
+ .replace(/[^a-z0-9._-]+/g, "-")
185
+ .replace(/^-+|-+$/g, "")
186
+ .slice(0, 80);
187
+ }
188
+
189
+ function findConfiguredLocalPeer(peers, localPeerId) {
190
+ if (!Array.isArray(peers) || !localPeerId) return undefined;
191
+ return peers.find((peer) => peer.peerId === localPeerId || normalizePeerId(peer.peerId) === localPeerId);
192
+ }
193
+
194
+ function explicitPeerProfileOptions(options = {}) {
195
+ const profile = {};
196
+ for (const field of ["role", "persona", "agentMd", "agentInstructions"]) {
197
+ const value = normalizedString(options[field]);
198
+ if (value) profile[field] = value;
199
+ }
200
+ return profile;
201
+ }
202
+
203
+ function normalizePeerProfile(source = {}) {
204
+ const profile = {};
205
+ const peerId = normalizedString(source.peerId);
206
+ if (peerId) profile.peerId = peerId;
207
+ for (const field of ["role", "persona", "agentMd", "agentInstructions", "agentMdPath", "agentMdContent"]) {
208
+ const value = normalizedString(source[field]);
209
+ if (value) profile[field] = value;
210
+ }
211
+ return profile;
212
+ }
213
+
214
+ function resolveProjectRelativeFile(cwd, configuredPath) {
215
+ const input = normalizedString(configuredPath);
216
+ if (!input) return { ok: false, reason: "path is empty" };
217
+ if (input.includes("\0")) return { ok: false, reason: "path is invalid" };
218
+ if (isAbsolute(input)) return { ok: false, reason: "path must be project-relative and must stay inside project cwd" };
219
+ const projectRoot = resolve(cwd || process.cwd());
220
+ const path = resolve(projectRoot, input);
221
+ const rel = relative(projectRoot, path);
222
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return { ok: false, reason: "path must be project-relative and must stay inside project cwd" };
223
+ return { ok: true, path };
224
+ }
225
+
226
+ function normalizedString(value) {
227
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
228
+ }
229
+
230
+ function safeSummaryString(value) {
231
+ const redacted = redactPeerAuditValue(value);
232
+ return normalizedString(typeof redacted === "string" ? redacted : undefined);
233
+ }
234
+
235
+ function configuredPeers(config, label, warnings) {
236
+ if (Array.isArray(config)) {
237
+ return config.flatMap((peer, index) => normalizeConfiguredPeer(peer, `${label}[${index}]`, warnings));
238
+ }
239
+ if (!isPlainObject(config)) return [];
240
+ return Object.entries(config)
241
+ .filter(([peerId]) => !["enabled", "experimental"].includes(peerId))
242
+ .flatMap(([peerId, value]) => normalizeConfiguredPeer({ peerId, ...(isPlainObject(value) ? value : {}) }, `${label}.${peerId}`, warnings));
243
+ }
244
+
245
+ function buildDefaultPeerEntries(options = {}) {
246
+ const localPeerId = normalizePeerId(options.localPeerId) || defaultLocalPeerId();
247
+ const entries = isPlainObject(options.seedPeers) ? { ...options.seedPeers } : {};
248
+ if (localPeerId && (options.role || options.persona)) {
249
+ entries[localPeerId] = {
250
+ ...(entries[localPeerId] || {}),
251
+ ...(normalizedString(options.role) ? { role: normalizedString(options.role) } : {}),
252
+ ...(normalizedString(options.persona) ? { persona: normalizedString(options.persona) } : {}),
253
+ trust: options.trust || entries[localPeerId]?.trust || "conversation",
254
+ };
255
+ }
256
+ return entries;
257
+ }
258
+
259
+ function normalizePeerManifest(manifest = {}) {
260
+ const source = isPlainObject(manifest) ? manifest : {};
261
+ return {
262
+ ...peerProtocolMetadata(),
263
+ ...(normalizedString(source.trust) ? { trust: normalizedString(source.trust) } : {}),
264
+ capabilities: isPlainObject(source.capabilities) ? clonePlain(source.capabilities) : {},
265
+ ...(Number.isInteger(source.protocolVersion) ? { protocolVersion: source.protocolVersion } : {}),
266
+ ...(Number.isInteger(source.minProtocolVersion) ? { minProtocolVersion: source.minProtocolVersion } : {}),
267
+ ...(Number.isInteger(source.maxProtocolVersion) ? { maxProtocolVersion: source.maxProtocolVersion } : {}),
268
+ };
269
+ }
270
+
271
+ function manifestDefaults(manifest = {}) {
272
+ return {
273
+ ...(manifest.trust ? { trust: manifest.trust } : {}),
274
+ ...(manifest.protocolVersion ? { protocolVersion: manifest.protocolVersion } : {}),
275
+ ...(manifest.minProtocolVersion ? { minProtocolVersion: manifest.minProtocolVersion } : {}),
276
+ ...(manifest.maxProtocolVersion ? { maxProtocolVersion: manifest.maxProtocolVersion } : {}),
277
+ ...(manifest.capabilities && Object.keys(manifest.capabilities).length ? { capabilities: manifest.capabilities } : {}),
278
+ };
279
+ }
280
+
281
+ function summarizePeerManifest(manifest = {}) {
282
+ return {
283
+ protocol: manifest.protocol || "pi-peer",
284
+ protocolVersion: manifest.protocolVersion || PEER_VERSION,
285
+ minProtocolVersion: manifest.minProtocolVersion || PEER_VERSION,
286
+ maxProtocolVersion: manifest.maxProtocolVersion || PEER_VERSION,
287
+ ...(manifest.trust ? { trust: manifest.trust } : {}),
288
+ capabilities: clonePlain(manifest.capabilities || {}),
289
+ };
290
+ }
291
+
292
+ function normalizeConfiguredPeer(peer, location, warnings) {
293
+ if (!isPlainObject(peer)) {
294
+ warnings.push(`${location} ignored because peer descriptor is not an object`);
295
+ return [];
296
+ }
297
+ if (peer.enabled === false) return [];
298
+ if (typeof peer.peerId !== "string" || !peer.peerId.trim()) {
299
+ warnings.push(`${location} ignored because peerId is missing`);
300
+ return [];
301
+ }
302
+ try {
303
+ const manifest = normalizePeerManifest(peer.manifest);
304
+ const merged = { ...manifestDefaults(manifest), ...peer, capabilities: { ...(manifest.capabilities || {}), ...(isPlainObject(peer.capabilities) ? peer.capabilities : {}) } };
305
+ delete merged.manifest;
306
+ return [normalizePeerDescriptor({ transport: "coms", trust: "read-only", ...merged })];
307
+ } catch (error) {
308
+ warnings.push(`${location} ignored: ${error.message}`);
309
+ return [];
310
+ }
311
+ }
312
+
313
+ function markUnsupportedTransport(peer, warnings) {
314
+ if (!peer.compatible) {
315
+ const unsupportedReason = `protocol v${peer.protocolVersion || "unknown"} is not compatible with this pi-peer runtime`;
316
+ warnings.push(`${peer.peerId}: ${unsupportedReason}`);
317
+ return { ...peer, status: "unsupported", unsupportedReason };
318
+ }
319
+ if (SUPPORTED_PEER_TRANSPORT_SET.has(peer.transport)) return peer;
320
+ const unsupportedReason = `transport '${peer.transport}' is configured for future work; only local coms is enabled in this prototype`;
321
+ warnings.push(`${peer.peerId}: ${unsupportedReason}`);
322
+ return { ...peer, status: "unsupported", unsupportedReason };
323
+ }
324
+
325
+ function settingsEnabled(settings) {
326
+ return settings?.experimental?.peerMessaging === true || settings?.peerMessaging?.enabled === true;
327
+ }
328
+
329
+ function configSource(hasSettings, hasPeerFile) {
330
+ if (hasSettings && hasPeerFile) return `${PEER_SETTINGS_RELATIVE_PATH}+${PEER_CONFIG_RELATIVE_PATH}`;
331
+ if (hasPeerFile) return PEER_CONFIG_RELATIVE_PATH;
332
+ if (hasSettings) return PEER_SETTINGS_RELATIVE_PATH;
333
+ return "none";
334
+ }
335
+
336
+ async function readJsonMaybe(path, readFile) {
337
+ try {
338
+ return JSON.parse(await readFile(path, "utf8"));
339
+ } catch (error) {
340
+ if (error?.code === "ENOENT") return undefined;
341
+ error.message = `Failed to read peer config ${path}: ${error.message}`;
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ function unique(values) {
347
+ return [...new Set(values)];
348
+ }
349
+
350
+ function clonePlain(value) {
351
+ return JSON.parse(JSON.stringify(value || {}));
352
+ }
353
+
354
+ function isPlainObject(value) {
355
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
356
+ }
@@ -0,0 +1,21 @@
1
+ export function installPeerRuntimeLifecycle(pi, options = {}) {
2
+ const runtimeFor = options.runtimeFor;
3
+ if (!pi || typeof pi.on !== "function" || typeof runtimeFor !== "function") return false;
4
+
5
+ pi.on("session_start", async (_event, ctx = {}) => {
6
+ const runtime = await runtimeFor(ctx.cwd || process.cwd());
7
+ if (runtime?.enabled && typeof runtime.start === "function") await runtime.start(ctx);
8
+ });
9
+
10
+ pi.on("agent_end", async (event, ctx = {}) => {
11
+ const runtime = await runtimeFor(ctx.cwd || process.cwd());
12
+ if (runtime?.enabled && typeof runtime.handleAgentEnd === "function") runtime.handleAgentEnd(event, ctx);
13
+ });
14
+
15
+ pi.on("session_shutdown", async (_event, ctx = {}) => {
16
+ const runtime = await runtimeFor(ctx.cwd || process.cwd());
17
+ if (typeof runtime?.shutdown === "function") await runtime.shutdown(ctx);
18
+ });
19
+
20
+ return true;
21
+ }