@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/extensions/pi-peer/index.ts +753 -0
- package/package.json +58 -0
- package/src/peers/command.mjs +289 -0
- package/src/peers/comms.mjs +676 -0
- package/src/peers/config.mjs +356 -0
- package/src/peers/extension-lifecycle.mjs +21 -0
- package/src/peers/goal-board.mjs +528 -0
- package/src/peers/guidance.mjs +45 -0
- package/src/peers/inbound-bridge.mjs +240 -0
- package/src/peers/local-transport.mjs +814 -0
- package/src/peers/message-store.mjs +114 -0
- package/src/peers/protocol.mjs +256 -0
- package/src/peers/role-collaboration-demo.mjs +71 -0
- package/src/peers/runtime.mjs +200 -0
- package/src/peers/status.mjs +158 -0
- package/src/peers/tool-results.mjs +154 -0
- package/src/utils.mjs +83 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { redactPeerAuditValue } from "./protocol.mjs";
|
|
5
|
+
|
|
6
|
+
export const PEER_MESSAGE_STORE_RELATIVE_PATH = ".pi/peer-messages.json";
|
|
7
|
+
|
|
8
|
+
export function createPeerMessageStore(root, options = {}) {
|
|
9
|
+
const path = options.path || messageStorePath(root);
|
|
10
|
+
const homeDir = options.homeDir || process.env.HOME || "";
|
|
11
|
+
let pendingSave = Promise.resolve();
|
|
12
|
+
return {
|
|
13
|
+
path,
|
|
14
|
+
async load() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
17
|
+
return normalizePeerMessageStore(parsed);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === "ENOENT") return normalizePeerMessageStore({});
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
async save(state = {}) {
|
|
24
|
+
pendingSave = pendingSave.catch(() => {}).then(async () => {
|
|
25
|
+
const normalized = normalizePeerMessageStore(redactPeerAuditValue(state, { homeDir }));
|
|
26
|
+
await mkdir(dirname(path), { recursive: true });
|
|
27
|
+
const tmp = `${path}.${process.pid}.${process.hrtime.bigint().toString(36)}.tmp`;
|
|
28
|
+
try {
|
|
29
|
+
await writeFile(tmp, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
30
|
+
await rename(tmp, path);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
await unlink(tmp).catch(() => {});
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
});
|
|
37
|
+
return pendingSave;
|
|
38
|
+
},
|
|
39
|
+
async flush() {
|
|
40
|
+
return pendingSave.catch(() => undefined);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizePeerMessageStore(state = {}) {
|
|
46
|
+
const messages = Array.isArray(state.messages) ? state.messages : Object.values(state.messages || {});
|
|
47
|
+
const conversations = Array.isArray(state.conversations) ? state.conversations : Object.values(state.conversations || {});
|
|
48
|
+
return {
|
|
49
|
+
version: 1,
|
|
50
|
+
updatedAt: typeof state.updatedAt === "string" && state.updatedAt ? state.updatedAt : new Date().toISOString(),
|
|
51
|
+
messages: messages.filter(isPlainObject).map(normalizeMessageSnapshot).filter(Boolean),
|
|
52
|
+
conversations: conversations.filter(isPlainObject).map(normalizeConversationSnapshot).filter(Boolean),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeMessageSnapshot(message) {
|
|
57
|
+
const messageId = cleanText(message.messageId);
|
|
58
|
+
const conversationId = cleanText(message.conversationId);
|
|
59
|
+
if (!messageId || !conversationId) return undefined;
|
|
60
|
+
return stripEmpty({
|
|
61
|
+
messageId,
|
|
62
|
+
conversationId,
|
|
63
|
+
peerId: cleanText(message.peerId),
|
|
64
|
+
status: cleanText(message.status || "unknown"),
|
|
65
|
+
request: isPlainObject(message.request) ? message.request : undefined,
|
|
66
|
+
response: isPlainObject(message.response) ? message.response : null,
|
|
67
|
+
responseEnvelope: isPlainObject(message.responseEnvelope) ? message.responseEnvelope : null,
|
|
68
|
+
events: Array.isArray(message.events) ? message.events.filter(isPlainObject).slice(-50) : [],
|
|
69
|
+
error: isPlainObject(message.error) ? message.error : null,
|
|
70
|
+
createdAt: cleanText(message.createdAt),
|
|
71
|
+
updatedAt: cleanText(message.updatedAt || message.createdAt),
|
|
72
|
+
lastEvent: isPlainObject(message.lastEvent) ? message.lastEvent : undefined,
|
|
73
|
+
lastHeartbeatAt: cleanText(message.lastHeartbeatAt),
|
|
74
|
+
recoveredAt: cleanText(message.recoveredAt),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeConversationSnapshot(conversation) {
|
|
79
|
+
const conversationId = cleanText(conversation.conversationId);
|
|
80
|
+
if (!conversationId) return undefined;
|
|
81
|
+
return stripEmpty({
|
|
82
|
+
conversationId,
|
|
83
|
+
peerIds: normalizeStringList(conversation.peerIds),
|
|
84
|
+
messageIds: normalizeStringList(conversation.messageIds),
|
|
85
|
+
status: cleanText(conversation.status || "unknown"),
|
|
86
|
+
createdAt: cleanText(conversation.createdAt),
|
|
87
|
+
updatedAt: cleanText(conversation.updatedAt || conversation.createdAt),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function messageStorePath(root) {
|
|
92
|
+
if (!root) throw new Error("peer message store requires root");
|
|
93
|
+
return resolvePath(root, PEER_MESSAGE_STORE_RELATIVE_PATH);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeStringList(value) {
|
|
97
|
+
return Array.isArray(value) ? [...new Set(value.map(cleanText).filter(Boolean))] : [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cleanText(value) {
|
|
101
|
+
return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isPlainObject(value) {
|
|
105
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stripEmpty(object) {
|
|
109
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => {
|
|
110
|
+
if (value === undefined || value === "") return false;
|
|
111
|
+
if (Array.isArray(value) && value.length === 0) return false;
|
|
112
|
+
return true;
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const PEER_PROTOCOL = "pi-peer";
|
|
2
|
+
const PEER_VERSION = 1;
|
|
3
|
+
const PEER_MIN_COMPATIBLE_VERSION = 1;
|
|
4
|
+
const PEER_MAX_COMPATIBLE_VERSION = 1;
|
|
5
|
+
const PEER_AUTH_TYPE_SHARED_TOKEN = "shared-token";
|
|
6
|
+
|
|
7
|
+
export { PEER_PROTOCOL, PEER_VERSION, PEER_MIN_COMPATIBLE_VERSION, PEER_MAX_COMPATIBLE_VERSION, PEER_AUTH_TYPE_SHARED_TOKEN };
|
|
8
|
+
|
|
9
|
+
export function peerProtocolMetadata() {
|
|
10
|
+
return {
|
|
11
|
+
protocol: PEER_PROTOCOL,
|
|
12
|
+
protocolVersion: PEER_VERSION,
|
|
13
|
+
minProtocolVersion: PEER_MIN_COMPATIBLE_VERSION,
|
|
14
|
+
maxProtocolVersion: PEER_MAX_COMPATIBLE_VERSION,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isPeerProtocolCompatible(source = {}) {
|
|
19
|
+
const protocol = nonEmptyString(source.protocol) ? source.protocol : PEER_PROTOCOL;
|
|
20
|
+
const version = Number.isInteger(source.protocolVersion) ? source.protocolVersion : Number.isInteger(source.version) ? source.version : PEER_VERSION;
|
|
21
|
+
const min = Number.isInteger(source.minProtocolVersion) ? source.minProtocolVersion : version;
|
|
22
|
+
const max = Number.isInteger(source.maxProtocolVersion) ? source.maxProtocolVersion : version;
|
|
23
|
+
return protocol === PEER_PROTOCOL && min <= PEER_VERSION && max >= PEER_MIN_COMPATIBLE_VERSION;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PEER_MESSAGE_TYPES = Object.freeze([
|
|
27
|
+
"hello",
|
|
28
|
+
"registry.query",
|
|
29
|
+
"registry.update",
|
|
30
|
+
"message.send",
|
|
31
|
+
"message.accepted",
|
|
32
|
+
"message.event",
|
|
33
|
+
"message.response",
|
|
34
|
+
"message.cancel",
|
|
35
|
+
"message.error",
|
|
36
|
+
"approval.request",
|
|
37
|
+
"approval.response",
|
|
38
|
+
"heartbeat",
|
|
39
|
+
"goodbye",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const PEER_MESSAGE_TYPE_SET = new Set(PEER_MESSAGE_TYPES);
|
|
43
|
+
|
|
44
|
+
export function validatePeerEnvelope(envelope) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
47
|
+
return { ok: false, errors: ["envelope must be an object"] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (envelope.protocol !== PEER_PROTOCOL) errors.push(`protocol must be ${PEER_PROTOCOL}`);
|
|
51
|
+
if (envelope.version !== PEER_VERSION) errors.push(`version must be ${PEER_VERSION}`);
|
|
52
|
+
if (!PEER_MESSAGE_TYPE_SET.has(envelope.type)) errors.push(`type must be one of ${PEER_MESSAGE_TYPES.join(", ")}`);
|
|
53
|
+
if (!nonEmptyString(envelope.id)) errors.push("id must be a non-empty string");
|
|
54
|
+
if (!nonEmptyString(envelope.conversationId)) errors.push("conversationId must be a non-empty string");
|
|
55
|
+
errors.push(...validatePeerAddress(envelope.source, "source"));
|
|
56
|
+
errors.push(...validatePeerAddress(envelope.target, "target"));
|
|
57
|
+
if (!nonEmptyString(envelope.timestamp) || Number.isNaN(Date.parse(envelope.timestamp))) errors.push("timestamp must be an ISO-like string");
|
|
58
|
+
if (envelope.correlationId !== undefined && !nonEmptyString(envelope.correlationId)) errors.push("correlationId must be a non-empty string when present");
|
|
59
|
+
if (envelope.causationId !== undefined && !nonEmptyString(envelope.causationId)) errors.push("causationId must be a non-empty string when present");
|
|
60
|
+
if (!Number.isInteger(envelope.hopCount) || envelope.hopCount < 0) errors.push("hopCount must be a non-negative integer");
|
|
61
|
+
if (!Number.isInteger(envelope.maxHopCount) || envelope.maxHopCount < 0) errors.push("maxHopCount must be a non-negative integer");
|
|
62
|
+
if (Number.isInteger(envelope.hopCount) && Number.isInteger(envelope.maxHopCount) && envelope.hopCount > envelope.maxHopCount) {
|
|
63
|
+
errors.push("hopCount must not exceed maxHopCount");
|
|
64
|
+
}
|
|
65
|
+
errors.push(...validatePeerAuth(envelope.auth));
|
|
66
|
+
if (!("body" in envelope)) errors.push("body is required");
|
|
67
|
+
|
|
68
|
+
return { ok: errors.length === 0, errors };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertValidPeerEnvelope(envelope) {
|
|
72
|
+
const validation = validatePeerEnvelope(envelope);
|
|
73
|
+
if (!validation.ok) {
|
|
74
|
+
const error = new Error(`Invalid Pi peer envelope: ${validation.errors.join("; ")}`);
|
|
75
|
+
error.code = "PI_PEER_INVALID_ENVELOPE";
|
|
76
|
+
error.errors = validation.errors;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
return envelope;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createPeerEnvelope({
|
|
83
|
+
type,
|
|
84
|
+
id = createPeerId(type && type.startsWith("message.") ? "msg" : "evt"),
|
|
85
|
+
conversationId = createPeerId("conv"),
|
|
86
|
+
source,
|
|
87
|
+
target,
|
|
88
|
+
timestamp = new Date().toISOString(),
|
|
89
|
+
correlationId,
|
|
90
|
+
causationId,
|
|
91
|
+
hopCount = 0,
|
|
92
|
+
maxHopCount = 1,
|
|
93
|
+
auth,
|
|
94
|
+
audit,
|
|
95
|
+
body = {},
|
|
96
|
+
}) {
|
|
97
|
+
const envelope = {
|
|
98
|
+
protocol: PEER_PROTOCOL,
|
|
99
|
+
version: PEER_VERSION,
|
|
100
|
+
type,
|
|
101
|
+
id,
|
|
102
|
+
conversationId,
|
|
103
|
+
source,
|
|
104
|
+
target,
|
|
105
|
+
timestamp,
|
|
106
|
+
hopCount,
|
|
107
|
+
maxHopCount,
|
|
108
|
+
body,
|
|
109
|
+
};
|
|
110
|
+
if (auth !== undefined) envelope.auth = auth;
|
|
111
|
+
if (correlationId !== undefined) envelope.correlationId = correlationId;
|
|
112
|
+
if (causationId !== undefined) envelope.causationId = causationId;
|
|
113
|
+
if (audit !== undefined) envelope.audit = audit;
|
|
114
|
+
return assertValidPeerEnvelope(envelope);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolvePeerAuthToken(source = {}, options = {}) {
|
|
118
|
+
const env = options.env || process.env || {};
|
|
119
|
+
if (nonEmptyString(source.authToken)) return source.authToken;
|
|
120
|
+
if (nonEmptyString(source.authTokenEnv)) {
|
|
121
|
+
const token = env[source.authTokenEnv];
|
|
122
|
+
return nonEmptyString(token) ? token : undefined;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createPeerEnvelopeAuth(source = {}, options = {}) {
|
|
128
|
+
const token = resolvePeerAuthToken(source, options);
|
|
129
|
+
return token ? { type: PEER_AUTH_TYPE_SHARED_TOKEN, token } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function attachPeerEnvelopeAuth(envelope, source = {}, options = {}) {
|
|
133
|
+
const auth = createPeerEnvelopeAuth(source, options);
|
|
134
|
+
return auth ? assertValidPeerEnvelope({ ...envelope, auth }) : envelope;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizePeerAddress(address, fallback = {}) {
|
|
138
|
+
const merged = { ...fallback, ...(address && typeof address === "object" ? address : {}) };
|
|
139
|
+
if (!nonEmptyString(merged.peerId)) throw new Error("peer address requires peerId");
|
|
140
|
+
return {
|
|
141
|
+
peerId: merged.peerId,
|
|
142
|
+
...(nonEmptyString(merged.sessionId) ? { sessionId: merged.sessionId } : {}),
|
|
143
|
+
...(nonEmptyString(merged.sessionFile) ? { sessionFile: merged.sessionFile } : {}),
|
|
144
|
+
...(nonEmptyString(merged.cwd) ? { cwd: merged.cwd } : {}),
|
|
145
|
+
...(nonEmptyString(merged.role) ? { role: merged.role } : {}),
|
|
146
|
+
...(nonEmptyString(merged.transport) ? { transport: merged.transport } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function normalizePeerMessageSendBody(body) {
|
|
151
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("peer message body must be an object");
|
|
152
|
+
if (!nonEmptyString(body.prompt)) throw new Error("peer message prompt must be a non-empty string");
|
|
153
|
+
return {
|
|
154
|
+
...body,
|
|
155
|
+
prompt: body.prompt,
|
|
156
|
+
intent: body.intent || "ask",
|
|
157
|
+
contextRefs: Array.isArray(body.contextRefs) ? body.contextRefs : [],
|
|
158
|
+
delivery: {
|
|
159
|
+
intoReceiverTurn: true,
|
|
160
|
+
responseMode: "final-assistant-message",
|
|
161
|
+
...(body.delivery && typeof body.delivery === "object" ? body.delivery : {}),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function normalizePeerMessageResponseBody(body) {
|
|
167
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
168
|
+
return { status: "ERROR", summary: "Peer transport returned an invalid response body" };
|
|
169
|
+
}
|
|
170
|
+
const status = ["OK", "OK_WITH_NOTES", "NEEDS_CONTEXT", "BLOCKED", "CANCELLED", "ERROR"].includes(body.status) ? body.status : "OK";
|
|
171
|
+
return { ...body, status };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function redactPeerAuditValue(value, options = {}) {
|
|
175
|
+
const homeDir = typeof options.homeDir === "string" && options.homeDir ? options.homeDir.replace(/\/+$/, "") : "";
|
|
176
|
+
return redactValue(value, homeDir, "");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validatePeerAddress(address, label) {
|
|
180
|
+
const errors = [];
|
|
181
|
+
if (!address || typeof address !== "object" || Array.isArray(address)) return [`${label} must be an object`];
|
|
182
|
+
if (!nonEmptyString(address.peerId)) errors.push(`${label}.peerId must be a non-empty string`);
|
|
183
|
+
for (const field of ["sessionId", "sessionFile", "cwd", "role", "transport"]) {
|
|
184
|
+
if (address[field] !== undefined && !nonEmptyString(address[field])) errors.push(`${label}.${field} must be a non-empty string when present`);
|
|
185
|
+
}
|
|
186
|
+
return errors;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validatePeerAuth(auth) {
|
|
190
|
+
if (auth === undefined) return [];
|
|
191
|
+
const errors = [];
|
|
192
|
+
if (!auth || typeof auth !== "object" || Array.isArray(auth)) return ["auth must be an object when present"];
|
|
193
|
+
if (auth.type !== PEER_AUTH_TYPE_SHARED_TOKEN) errors.push(`auth.type must be ${PEER_AUTH_TYPE_SHARED_TOKEN}`);
|
|
194
|
+
if (!nonEmptyString(auth.token)) errors.push("auth.token must be a non-empty string");
|
|
195
|
+
return errors;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function redactValue(value, homeDir, key) {
|
|
199
|
+
if (value === null || value === undefined) return value;
|
|
200
|
+
if (typeof value === "string") return redactString(value, homeDir);
|
|
201
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
202
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, homeDir, key));
|
|
203
|
+
if (typeof value !== "object") return String(value);
|
|
204
|
+
|
|
205
|
+
const out = {};
|
|
206
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
207
|
+
if (isSensitiveEnvKey(childKey) || isEnvKey(childKey)) {
|
|
208
|
+
out[childKey] = "[REDACTED_ENV]";
|
|
209
|
+
} else if (isSensitiveKey(childKey)) {
|
|
210
|
+
out[childKey] = "[REDACTED]";
|
|
211
|
+
} else {
|
|
212
|
+
out[childKey] = redactValue(childValue, homeDir, childKey);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function redactString(input, homeDir) {
|
|
219
|
+
let text = input;
|
|
220
|
+
if (homeDir) text = text.split(homeDir).join("~");
|
|
221
|
+
return text
|
|
222
|
+
.replace(/ghp_[A-Za-z0-9_]{20,}/g, "[REDACTED_TOKEN]")
|
|
223
|
+
.replace(/github_pat_[A-Za-z0-9_]+/g, "[REDACTED_TOKEN]")
|
|
224
|
+
.replace(/sk-[A-Za-z0-9][A-Za-z0-9_-]{9,}/g, "[REDACTED_TOKEN]")
|
|
225
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED_TOKEN]")
|
|
226
|
+
.replace(/(token|secret|password|api[_-]?key)=([^\s&]+)/gi, "$1=[REDACTED]");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isSensitiveKey(key) {
|
|
230
|
+
const normalized = String(key || "").toLowerCase().replace(/[-_]/g, "");
|
|
231
|
+
return normalized.includes("token")
|
|
232
|
+
|| normalized.includes("secret")
|
|
233
|
+
|| normalized.includes("password")
|
|
234
|
+
|| normalized.includes("credential")
|
|
235
|
+
|| normalized.includes("authorization")
|
|
236
|
+
|| normalized.includes("apikey")
|
|
237
|
+
|| normalized === "auth"
|
|
238
|
+
|| normalized.endsWith("auth");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isEnvKey(key) {
|
|
242
|
+
return /^env(ironment)?$/i.test(key);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isSensitiveEnvKey(key) {
|
|
246
|
+
const normalized = String(key || "").toLowerCase().replace(/[-_]/g, "");
|
|
247
|
+
return normalized.endsWith("tokenenv") || normalized.endsWith("secretenv") || normalized.endsWith("credentialenv");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function nonEmptyString(value) {
|
|
251
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createPeerId(prefix) {
|
|
255
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
|
|
256
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const REQUIRED_ROLES = ['planner', 'worker', 'reviewer'];
|
|
2
|
+
|
|
3
|
+
function normalizeRoles(roles) {
|
|
4
|
+
if (Array.isArray(roles)) {
|
|
5
|
+
return roles.map((entry) => {
|
|
6
|
+
if (typeof entry === 'string') return entry.trim();
|
|
7
|
+
if (entry && typeof entry === 'object' && typeof entry.role === 'string') {
|
|
8
|
+
return entry.role.trim();
|
|
9
|
+
}
|
|
10
|
+
return '';
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (roles && typeof roles === 'object') {
|
|
15
|
+
return Object.entries(roles).map(([role, value]) => {
|
|
16
|
+
if (typeof value === 'string' && value.trim() === '') return '';
|
|
17
|
+
return role.trim();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateRequiredRoles(roles) {
|
|
25
|
+
const normalizedRoles = normalizeRoles(roles);
|
|
26
|
+
|
|
27
|
+
if (normalizedRoles.some((role) => role === '')) {
|
|
28
|
+
throw new Error('Missing required peer role: roles must include non-blank planner, worker, and reviewer entries.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const roleSet = new Set(normalizedRoles);
|
|
32
|
+
const missingRoles = REQUIRED_ROLES.filter((role) => !roleSet.has(role));
|
|
33
|
+
if (missingRoles.length > 0) {
|
|
34
|
+
throw new Error(`Missing required peer role: ${missingRoles.join(', ')}.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const unexpectedRoles = normalizedRoles.filter((role) => !REQUIRED_ROLES.includes(role));
|
|
38
|
+
if (unexpectedRoles.length > 0) {
|
|
39
|
+
throw new Error(`Unexpected peer role: ${unexpectedRoles.join(', ')}. Required roles are planner, worker, and reviewer.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildRoleCollaborationTranscript({ goal, roles } = {}) {
|
|
44
|
+
const normalizedGoal = typeof goal === 'string' ? goal.trim() : '';
|
|
45
|
+
if (normalizedGoal === '') {
|
|
46
|
+
throw new Error('A non-blank collaboration goal is required.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
validateRequiredRoles(roles);
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
role: 'planner',
|
|
54
|
+
summary: `Planner defines the goal: ${normalizedGoal}`,
|
|
55
|
+
goal: normalizedGoal,
|
|
56
|
+
action: 'scope',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
role: 'worker',
|
|
60
|
+
summary: `Worker implements the goal: ${normalizedGoal}`,
|
|
61
|
+
goal: normalizedGoal,
|
|
62
|
+
action: 'implement',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
role: 'reviewer',
|
|
66
|
+
summary: `Reviewer verifies the goal: ${normalizedGoal}`,
|
|
67
|
+
goal: normalizedGoal,
|
|
68
|
+
action: 'review',
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { InMemoryPeerTransport, MemoryPeerRegistry, createPeerComms } from "./comms.mjs";
|
|
4
|
+
import { applyLocalPeerIdOverride, loadLocalPeerProfile, loadPeerRuntimeConfig, summarizePeerRuntimeConfig } from "./config.mjs";
|
|
5
|
+
import { deriveGoalState, loadPeerGoalBoard } from "./goal-board.mjs";
|
|
6
|
+
import { createInboundPromptBridge } from "./inbound-bridge.mjs";
|
|
7
|
+
import { LocalPeerTransport, createLocalPeerEndpoint, discoverLocalPeerEndpoints } from "./local-transport.mjs";
|
|
8
|
+
import { createPeerMessageStore } from "./message-store.mjs";
|
|
9
|
+
import { redactPeerAuditValue } from "./protocol.mjs";
|
|
10
|
+
import { collectPeerRuntimeStatus, deriveFanoutSuggestion } from "./status.mjs";
|
|
11
|
+
|
|
12
|
+
export const PI_PEER_RUNTIME_ENTRY_TYPE = "pi-peer-runtime";
|
|
13
|
+
export const PI_PEER_AUDIT_ENTRY_TYPE = "pi-peer-audit";
|
|
14
|
+
|
|
15
|
+
export async function createPeerRuntime(cwd, options = {}) {
|
|
16
|
+
const loadedConfig = options.config || await loadPeerRuntimeConfig(cwd, options);
|
|
17
|
+
let config = applyLocalPeerIdOverride(loadedConfig, { localPeerId: options.localPeerId, env: options.env || process.env });
|
|
18
|
+
const persistence = createPiPeerPersistence(options.pi, { homeDir: options.homeDir });
|
|
19
|
+
const messageStore = options.messageStore || (config.enabled ? createPeerMessageStore(cwd, { homeDir: options.homeDir }) : undefined);
|
|
20
|
+
const persistedMessages = messageStore ? await messageStore.load().catch(() => undefined) : undefined;
|
|
21
|
+
const configuredPeers = config.enabled ? config.peers : [];
|
|
22
|
+
const configuredPeerById = new Map(configuredPeers.map((peer) => [peer.peerId, peer]));
|
|
23
|
+
const registry = new MemoryPeerRegistry(configuredPeers);
|
|
24
|
+
const localPeerId = config.localPeerId || `pi-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
25
|
+
if (!config.localPeerId) config = { ...config, localPeerId, localPeerIdSource: "generated" };
|
|
26
|
+
const localPeerProfileResult = await loadLocalPeerProfile(cwd, config, { ...options, localPeerId });
|
|
27
|
+
const localPeerProfile = localPeerProfileResult.profile;
|
|
28
|
+
config = { ...config, localPeerProfile, warnings: uniqueStrings([...(config.warnings || []), ...localPeerProfileResult.warnings]) };
|
|
29
|
+
const memoryTransport = options.memoryTransport || new InMemoryPeerTransport(options.transportOptions || {});
|
|
30
|
+
const transport = options.transport || new LocalPeerTransport({
|
|
31
|
+
discoveryDir: options.discoveryDir,
|
|
32
|
+
env: options.env,
|
|
33
|
+
fallback: memoryTransport,
|
|
34
|
+
timeoutMs: options.transportTimeoutMs,
|
|
35
|
+
});
|
|
36
|
+
const inboundBridge = options.inboundBridge || (options.pi && typeof options.pi.sendMessage === "function"
|
|
37
|
+
? createInboundPromptBridge({ pi: options.pi, cwd, responseTimeoutMs: options.responseTimeoutMs, responderProfile: localPeerProfile, homeDir: options.homeDir })
|
|
38
|
+
: undefined);
|
|
39
|
+
const comms = createPeerComms({
|
|
40
|
+
registry,
|
|
41
|
+
transport,
|
|
42
|
+
localPeerId,
|
|
43
|
+
localAddress: options.localAddress || {},
|
|
44
|
+
homeDir: options.homeDir || process.env.HOME || "",
|
|
45
|
+
auditSink(entry) {
|
|
46
|
+
persistence.appendAudit(entry);
|
|
47
|
+
},
|
|
48
|
+
messageStore,
|
|
49
|
+
persistedState: persistedMessages,
|
|
50
|
+
});
|
|
51
|
+
let localEndpoint;
|
|
52
|
+
let discoveredPeerIds = new Set();
|
|
53
|
+
|
|
54
|
+
const runtime = {
|
|
55
|
+
enabled: config.enabled,
|
|
56
|
+
source: config.source,
|
|
57
|
+
config,
|
|
58
|
+
comms,
|
|
59
|
+
summary: { ...summarizePeerRuntimeConfig(config), localPeerId },
|
|
60
|
+
localPeerId,
|
|
61
|
+
cwd,
|
|
62
|
+
get localEndpoint() {
|
|
63
|
+
return localEndpoint?.descriptor;
|
|
64
|
+
},
|
|
65
|
+
async refreshLocalPeers() {
|
|
66
|
+
if (!config.enabled) return [];
|
|
67
|
+
const peers = await discoverLocalPeerEndpoints({ discoveryDir: options.discoveryDir, excludePeerId: localPeerId });
|
|
68
|
+
const nextDiscoveredPeerIds = new Set(peers.map((peer) => peer.peerId));
|
|
69
|
+
for (const peerId of discoveredPeerIds) {
|
|
70
|
+
if (nextDiscoveredPeerIds.has(peerId)) continue;
|
|
71
|
+
if (configuredPeerById.has(peerId)) await registry.registerPeer(configuredPeerById.get(peerId));
|
|
72
|
+
else await registry.unregisterPeer(peerId);
|
|
73
|
+
}
|
|
74
|
+
for (const peer of peers) await registry.registerPeer(mergeDiscoveredPeerWithConfiguredAuth(peer, configuredPeerById.get(peer.peerId)));
|
|
75
|
+
discoveredPeerIds = nextDiscoveredPeerIds;
|
|
76
|
+
return peers;
|
|
77
|
+
},
|
|
78
|
+
async start(ctx = {}) {
|
|
79
|
+
if (!config.enabled) return runtime;
|
|
80
|
+
const handler = options.inboundResponder || (inboundBridge ? (envelope, _descriptor, context) => inboundBridge.handleEnvelope(envelope, context) : undefined);
|
|
81
|
+
if (!localEndpoint && handler) {
|
|
82
|
+
const endpoint = createLocalPeerEndpoint({
|
|
83
|
+
peerId: localPeerId,
|
|
84
|
+
cwd,
|
|
85
|
+
sessionId: options.sessionId || ctx.sessionId,
|
|
86
|
+
role: localPeerProfile.role || options.role,
|
|
87
|
+
persona: localPeerProfile.persona || options.persona,
|
|
88
|
+
trust: options.trust || config.manifest?.trust || "conversation",
|
|
89
|
+
maxHopCount: options.maxHopCount,
|
|
90
|
+
capabilities: options.capabilities || config.manifest?.capabilities,
|
|
91
|
+
protocolVersion: config.manifest?.protocolVersion,
|
|
92
|
+
minProtocolVersion: config.manifest?.minProtocolVersion,
|
|
93
|
+
maxProtocolVersion: config.manifest?.maxProtocolVersion,
|
|
94
|
+
discoveryDir: options.discoveryDir,
|
|
95
|
+
authToken: options.authToken,
|
|
96
|
+
authTokenEnv: options.authTokenEnv,
|
|
97
|
+
activeHeartbeatIntervalMs: options.activeHeartbeatIntervalMs,
|
|
98
|
+
presenceHeartbeatIntervalMs: options.presenceHeartbeatIntervalMs,
|
|
99
|
+
handler,
|
|
100
|
+
});
|
|
101
|
+
await endpoint.start();
|
|
102
|
+
localEndpoint = endpoint;
|
|
103
|
+
}
|
|
104
|
+
await runtime.refreshLocalPeers();
|
|
105
|
+
persistence.appendRuntime({ kind: "runtime.started", at: new Date().toISOString(), localPeerId, endpoint: runtime.localEndpoint });
|
|
106
|
+
return runtime;
|
|
107
|
+
},
|
|
108
|
+
handleAgentEnd(event, ctx) {
|
|
109
|
+
return inboundBridge ? inboundBridge.handleAgentEnd(event, ctx) : false;
|
|
110
|
+
},
|
|
111
|
+
recordInboundProgress(progress) {
|
|
112
|
+
return inboundBridge ? inboundBridge.recordProgress(progress) : { ok: false, reason: "no active inbound peer task" };
|
|
113
|
+
},
|
|
114
|
+
async shutdown() {
|
|
115
|
+
if (inboundBridge) {
|
|
116
|
+
inboundBridge.dispose("Peer runtime shutting down");
|
|
117
|
+
await localEndpoint?.drain?.();
|
|
118
|
+
}
|
|
119
|
+
if (localEndpoint) {
|
|
120
|
+
await localEndpoint.stop();
|
|
121
|
+
localEndpoint = undefined;
|
|
122
|
+
}
|
|
123
|
+
persistence.appendRuntime({ kind: "runtime.stopped", at: new Date().toISOString(), localPeerId });
|
|
124
|
+
},
|
|
125
|
+
async dispose() {
|
|
126
|
+
await runtime.shutdown();
|
|
127
|
+
await comms.dispose();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
persistence.appendRuntime({ kind: "runtime.loaded", at: new Date().toISOString(), ...runtime.summary });
|
|
131
|
+
return runtime;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function uniqueStrings(values) {
|
|
135
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim()))];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mergeDiscoveredPeerWithConfiguredAuth(peer, configuredPeer) {
|
|
139
|
+
if (!configuredPeer) return peer;
|
|
140
|
+
return {
|
|
141
|
+
...configuredPeer,
|
|
142
|
+
...peer,
|
|
143
|
+
...(configuredPeer.authToken !== undefined ? { authToken: configuredPeer.authToken } : {}),
|
|
144
|
+
...(configuredPeer.authTokenEnv !== undefined ? { authTokenEnv: configuredPeer.authTokenEnv } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createPiPeerPersistence(pi, options = {}) {
|
|
149
|
+
const canAppend = Boolean(pi && typeof pi.appendEntry === "function");
|
|
150
|
+
const append = (customType, data) => {
|
|
151
|
+
if (!canAppend) return false;
|
|
152
|
+
const redacted = redactPeerAuditValue(data, { homeDir: options.homeDir || process.env.HOME || "" });
|
|
153
|
+
try {
|
|
154
|
+
const result = pi.appendEntry(customType, redacted);
|
|
155
|
+
if (result && typeof result.catch === "function") result.catch(() => {});
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
supported: canAppend,
|
|
163
|
+
appendRuntime(data) {
|
|
164
|
+
return append(PI_PEER_RUNTIME_ENTRY_TYPE, data);
|
|
165
|
+
},
|
|
166
|
+
appendAudit(entry) {
|
|
167
|
+
return append(PI_PEER_AUDIT_ENTRY_TYPE, entry);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function getPeerRuntimeValue(runtime, id) {
|
|
173
|
+
if (id === "runtime") return { type: "runtime", value: await collectPeerRuntimeStatus(runtime) };
|
|
174
|
+
if (id === "goals") return { type: "goals", value: await loadPeerGoalBoard(runtime.cwd) };
|
|
175
|
+
if (id === "goal" || String(id || "").startsWith("goal_")) {
|
|
176
|
+
const board = await loadPeerGoalBoard(runtime.cwd);
|
|
177
|
+
const goalId = id === "goal" ? board.currentGoalId : id;
|
|
178
|
+
const goal = goalId ? board.goals[goalId] : undefined;
|
|
179
|
+
return goal ? { type: "goal", value: deriveGoalState(goal) } : { type: "missing", value: undefined };
|
|
180
|
+
}
|
|
181
|
+
if (id === "tasks") return { type: "tasks", value: { active: await runtime.comms.listTasks({ active: true }), all: await runtime.comms.listTasks(), note: "Active tasks are queued/running peer messages; disconnected tasks were restored from the local message store and are not awaitable." } };
|
|
182
|
+
if (id === "fanout") {
|
|
183
|
+
const peers = await runtime.comms.listPeers();
|
|
184
|
+
const messages = await runtime.comms.listMessages();
|
|
185
|
+
const suggestion = deriveFanoutSuggestion(peers, messages);
|
|
186
|
+
return { type: "fanout", value: { ...suggestion, checklist: ["Run peer_list before multi-lane work", "Create or reuse /peer goal", "Use /peer goal fanout or peer_send goalId+claimedPaths", "Final response must include Fan-out used: yes/no and peer handles"] } };
|
|
187
|
+
}
|
|
188
|
+
if (id === "audit") return { type: "audit", value: await runtime.comms.getAuditEntries() };
|
|
189
|
+
|
|
190
|
+
const message = await runtime.comms.getMessage(id);
|
|
191
|
+
if (message) return { type: "message", value: message };
|
|
192
|
+
|
|
193
|
+
const conversation = await runtime.comms.getConversation(id);
|
|
194
|
+
if (conversation) return { type: "conversation", value: conversation };
|
|
195
|
+
|
|
196
|
+
const peer = await runtime.comms.getPeer(id);
|
|
197
|
+
if (peer) return { type: "peer", value: peer };
|
|
198
|
+
|
|
199
|
+
return { type: "missing", value: undefined };
|
|
200
|
+
}
|