persona-mouth: Matrix ear and mouth for Personas (brake-refusing sends, token only in the Authorization header, cursor after ack, one ring per idle window); delete the Discord posting actuator (squash of eureka-body/persona-mouth 1894f65..ab2bdc6; Soul s1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1894f653d0
commit
7b630312e1
20 changed files with 923 additions and 302 deletions
114
tools/persona-chat-documents.mjs
Normal file
114
tools/persona-chat-documents.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// gamecult.persona.chat_*.v1: the documents Huginn and the Matrix mouth exchange.
|
||||
// Each kind is a positional tuple (the CultCache payload shape); this module owns
|
||||
// the field order, the parsers and the envelope check. It does no I/O and loads no
|
||||
// runtime, so the wire encoder (MessagePack) stays with the caller.
|
||||
|
||||
export const kinds = Object.freeze({
|
||||
pendingRequest: "gamecult.persona.chat_pending_request.v1",
|
||||
pending: "gamecult.persona.chat_pending.v1",
|
||||
ack: "gamecult.persona.chat_ack.v1",
|
||||
sendRequest: "gamecult.persona.chat_send_request.v1",
|
||||
sendReceipt: "gamecult.persona.chat_send_receipt.v1",
|
||||
});
|
||||
|
||||
export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed"]);
|
||||
|
||||
const maxTextBytes = 4000;
|
||||
const maxContext = 20;
|
||||
|
||||
export function pendingRequestTuple({ persona, afterSeq }) {
|
||||
return [text(persona, "persona"), seq(afterSeq, "after_seq")];
|
||||
}
|
||||
export function parsePendingRequest(t) {
|
||||
const [persona, afterSeq] = tuple(t, 2, "chat_pending_request");
|
||||
return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") };
|
||||
}
|
||||
|
||||
export function ackTuple({ persona, throughSeq }) {
|
||||
return [text(persona, "persona"), seq(throughSeq, "through_seq")];
|
||||
}
|
||||
export function parseAck(t) {
|
||||
const [persona, throughSeq] = tuple(t, 2, "chat_ack");
|
||||
return { persona: text(persona, "persona"), throughSeq: seq(throughSeq, "through_seq") };
|
||||
}
|
||||
|
||||
export function sendRequestTuple({ requestId, persona, roomAlias, text: body }) {
|
||||
return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body)];
|
||||
}
|
||||
export function parseSendRequest(t) {
|
||||
const [requestId, persona, roomAlias, body] = tuple(t, 4, "chat_send_request");
|
||||
if (typeof body !== "string") throw new Error("chat_send_request text must be a string");
|
||||
if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound");
|
||||
return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body };
|
||||
}
|
||||
|
||||
export function sendReceiptTuple({ requestId, eventId = null, refusal = null }) {
|
||||
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
|
||||
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
|
||||
return [text(requestId, "request_id"), eventId, refusal];
|
||||
}
|
||||
export function parseSendReceipt(t) {
|
||||
const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt");
|
||||
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
|
||||
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal };
|
||||
}
|
||||
|
||||
export function deliveryTuple(d) {
|
||||
return [
|
||||
seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"),
|
||||
text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"),
|
||||
d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]),
|
||||
];
|
||||
}
|
||||
export function parseDelivery(t) {
|
||||
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context] = tuple(t, 9, "delivery");
|
||||
if (!Array.isArray(context)) throw new Error("delivery context must be a list");
|
||||
return {
|
||||
seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"),
|
||||
sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"),
|
||||
context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }),
|
||||
};
|
||||
}
|
||||
|
||||
export function pendingTuple({ persona, deliveries }) {
|
||||
return [text(persona, "persona"), deliveries.map(deliveryTuple)];
|
||||
}
|
||||
export function parsePending(t) {
|
||||
const [persona, deliveries] = tuple(t, 2, "chat_pending");
|
||||
if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list");
|
||||
return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) };
|
||||
}
|
||||
|
||||
// The CultNet document_put_raw envelope every chat_* document travels in.
|
||||
export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) {
|
||||
return {
|
||||
schemaVersion: "cultnet.document_put_raw.v0",
|
||||
messageId: `${kind}:${recordKey}`,
|
||||
document: {
|
||||
schemaId: kind, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack", payload,
|
||||
sourceRuntimeId, sourceAgentId, sourceRole, tags: ["cultnet.transport.rudp.v0"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function checkEnvelope(message, allowedKinds) {
|
||||
const d = message?.document;
|
||||
if (message?.schemaVersion !== "cultnet.document_put_raw.v0" || !d) throw new Error("not a document_put_raw message");
|
||||
if (!allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`);
|
||||
if (d.payloadEncoding !== "messagepack") throw new Error("chat documents are MessagePack");
|
||||
if (!Array.isArray(d.tags) || d.tags.length !== 1 || d.tags[0] !== "cultnet.transport.rudp.v0") throw new Error("chat document tags substituted authority");
|
||||
return d;
|
||||
}
|
||||
|
||||
function tuple(t, n, what) {
|
||||
if (!Array.isArray(t) || t.length !== n) throw new Error(`${what} must be a ${n}-field tuple`);
|
||||
return t;
|
||||
}
|
||||
function text(v, what) {
|
||||
if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`);
|
||||
return v;
|
||||
}
|
||||
function seq(v, what) {
|
||||
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`);
|
||||
return v;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
export const requestType = "epiphany.persona_discord_delivery_request";
|
||||
export const requestSchema = "epiphany.persona_discord_delivery_request.v0";
|
||||
export const requestSigningPurpose = "epiphany.persona-discord.delivery-request.v0";
|
||||
export const requestIdentityDomain = "epiphany.persona-discord-delivery-request.identity.v0\0";
|
||||
export const requestSignatureDomain = "epiphany.persona-discord-delivery-request.signature.v0\0";
|
||||
export const receiptType = "bifrost.persona_discord_delivery_receipt";
|
||||
export const receiptSchema = "bifrost.persona_discord_delivery_receipt.v0";
|
||||
export const receiptSigningPurpose = "bifrost.persona-discord.delivery-receipt.v0";
|
||||
export const receiptIdentityDomain = "bifrost.persona-discord-delivery-receipt.identity.v0\0";
|
||||
export const receiptSignatureDomain = "bifrost.persona-discord-delivery-receipt.signature.v0\0";
|
||||
export const executionType = "bifrost.persona_discord_delivery_execution";
|
||||
export const executionSchema = "bifrost.persona_discord_delivery_execution.v0";
|
||||
|
||||
export function buildPersonaDiscordDeliveryDefinitions(defineDocumentType) {
|
||||
const make=(type,schema,name,parse,members)=>defineDocumentType({type,schemaName:type,schemaId:schema,schemaVersion:schema,contentHash:schema,global:false,name,schema:{parse},members});
|
||||
return {
|
||||
request:make(requestType,requestSchema,"requestId",parseRequest,requestMembers),
|
||||
receipt:make(receiptType,receiptSchema,"receiptId",parseReceipt,receiptMembers),
|
||||
execution:make(executionType,executionSchema,"requestId",parseExecution),
|
||||
};
|
||||
}
|
||||
|
||||
const requestMembers = ["schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="signerSignature"?"byte[]":"string",isName:memberName==="requestId"}));
|
||||
const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadSha256","status","channelId","replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl","completedAt","providerIdentityId","privateStateExposed","providerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="providerSignature"?"byte[]":"string",isName:memberName==="receiptId"}));
|
||||
|
||||
export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];}
|
||||
export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];}
|
||||
export function receiptDocumentTuple(v){return [...receiptSigningTuple(v),v.providerSignature];}
|
||||
|
||||
export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;}
|
||||
export function parseReceipt(value){const v=typed(value,receiptMembers,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;}
|
||||
export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;}
|
||||
function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;}
|
||||
function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);}
|
||||
function text(v,label){bounded(v,label,256);}
|
||||
function bounded(v,label,maxBytes){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>maxBytes||/\p{Cc}/u.test(v))throw new Error(`${label} is empty, oversized, or contains control characters.`);}
|
||||
function content(v){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>1900||v.includes("\0"))throw new Error("content is empty, exceeds 1900 UTF-8 bytes, or contains NUL.");}
|
||||
function optionalText(v,label){if(typeof v!=="string")throw new Error(`${label} must be a string.`);}
|
||||
function exact(v,w,label){if(v!==w)throw new Error(`${label} must be ${w}.`);}
|
||||
function bytes64(v,label){if(!(v instanceof Uint8Array)||v.length!==64)throw new Error(`${label} must be 64 bytes.`);}
|
||||
function sha(v,label){if(!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be lowercase sha256.`);}
|
||||
function date(v,label){const parsed=Date.parse(v);if(Number.isNaN(parsed))throw new Error(`${label} must be RFC3339.`);return parsed;}
|
||||
function closed(v,keys,label){const allowed=new Set(keys);for(const key of Object.keys(v))if(!allowed.has(key))throw new Error(`${label} contains unknown field ${key}.`);}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import {createHash,randomBytes} from "node:crypto";
|
||||
import {spawnSync} from "node:child_process";
|
||||
import {access,readFile,writeFile,mkdir} from "node:fs/promises";
|
||||
import {constants} from "node:fs";
|
||||
import {dirname,resolve} from "node:path";
|
||||
import {enrollDomainIdentity,loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs";
|
||||
import {buildPersonaDiscordDeliveryDefinitions,executionSchema,receiptIdentityDomain,receiptSchema,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "./persona-discord-delivery-documents.mjs";
|
||||
import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs";
|
||||
import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs";
|
||||
|
||||
export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};}
|
||||
export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){let identity;try{await access(privateKeyPath,constants.F_OK);identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}const anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};}
|
||||
|
||||
export async function processPersonaDiscordDeliveries(options){
|
||||
const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType);
|
||||
requireSeparate([requestStorePath,receiptStorePath,executionStorePath]);
|
||||
const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain);
|
||||
const results=[];
|
||||
for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){
|
||||
const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId));
|
||||
if(existingReceipt){if(existingReceipt.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its terminal receipt.`);continue;}
|
||||
if(existingExecution){if(existingExecution.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its execution journal.`);if(existingExecution.status==="running"){results.push(await terminal("unknown",request,digest,"delivery outcome was indeterminate after service interruption",{runtime,defs,receipts,executions,identity}));continue;}if(existingExecution.status!=="permit_pending")continue;}
|
||||
let failure="";
|
||||
try{validateRequest(request,unsigned,anchor,targetAdmitted);}catch(error){failure=error instanceof Error?error.message:String(error);}
|
||||
if(failure){results.push(await terminal("failed",request,digest,failure,{runtime,defs,receipts,executions,identity}));continue;}
|
||||
if(existingExecution&&Date.parse(existingExecution.permitExpiresAt)<Date.now()){results.push(await terminal("unknown",request,digest,"permit issuance outcome was indeterminate after its durable request window expired",{runtime,defs,receipts,executions,identity}));continue;}
|
||||
const permitIssuedAt=existingExecution?.permitIssuedAt??new Date().toISOString(),permitExpiresAt=existingExecution?.permitExpiresAt??new Date(Date.parse(permitIssuedAt)+5000).toISOString(),nonce=existingExecution?.permitNonce??randomBytes(16).toString("hex");
|
||||
if(!existingExecution)await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"permit_pending",recordedAt:permitIssuedAt,permitNonce:nonce,permitIssuedAt,permitExpiresAt,detail:"permit request intent persisted before network"});
|
||||
let permit;
|
||||
try{permit=await (options.requestPermit??requestPersonaDiscordPermit)({endpoint:options.epiphanyPermitEndpoint,requestId:request.requestId,requestPayloadSha256:digest,targetRuntimeId:request.targetRuntimeId,nonce,issuedAt:permitIssuedAt,expiresAt:permitExpiresAt,privateKeyPath:options.permitRequesterPrivateKeyPath,epiphanyPermitAnchorPath:options.epiphanyPermitAnchorPath,runtime:options.permitRuntime,timeoutMs:options.permitTimeoutMs});}
|
||||
catch(error){results.push({requestId:request.requestId,status:"permit_pending",detail:error instanceof Error?error.message:String(error)});continue;}
|
||||
await executions.cache.put(defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status:"running",recordedAt:new Date().toISOString(),detail:`permit ${permit.permitId} durably consumed before external delivery`});
|
||||
if(Date.parse(permit.expiresAt)<=Date.now()){results.push(await terminal("failed",request,digest,"Epiphany brake permit expired after durable running journal and before Discord actuation",{runtime,defs,receipts,executions,identity}));continue;}
|
||||
try{
|
||||
const posted=post(request,options);
|
||||
results.push(await terminal("completed",request,digest,"",{runtime,defs,receipts,executions,identity,posted}));
|
||||
}catch(error){results.push(await terminal("unknown",request,digest,error instanceof Error?error.message:String(error),{runtime,defs,receipts,executions,identity}));}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function inspectPersonaDiscordDeliveryReadiness(options){try{requireSeparate([options.requestStorePath,options.receiptStorePath,options.executionStorePath,options.privateKeyPath,options.permitRequesterPrivateKeyPath]);const anchor=await readAnchor(options.runtime,options.epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(options.privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),permitRequester=await loadDomainIdentity(options.permitRequesterPrivateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);if(!options.epiphanyPermitEndpoint)throw new Error("Epiphany permit endpoint is absent");await access(options.epiphanyPermitAnchorPath,constants.R_OK);await accessStoreOrWritableParent(options.requestStorePath,constants.R_OK);await accessStoreOrWritableParent(options.receiptStorePath,constants.R_OK|constants.W_OK);return {ready:true,requestSignerIdentityId:anchor.identityId,receiptSignerIdentityId:identity.identityId,permitRequesterIdentityId:permitRequester.identityId,reasons:[]};}catch(error){return {ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:[error instanceof Error?error.message:String(error)]};}}
|
||||
|
||||
function validateRequest(request,unsigned,anchor,targetAdmitted){if(request.signerIdentityId!==anchor.identityId)throw new Error("request signer is not the pinned Epiphany Persona mouth identity");if(!verifyDomainPurpose(anchor.publicKey,requestSignatureDomain,requestSigningPurpose,unsigned,request.signerSignature))throw new Error("request signature is invalid");if(`sha256-${sha(Buffer.from(request.content,"utf8"))}`!==request.contentSha256)throw new Error("request content digest is invalid");const now=Date.now(),issued=Date.parse(request.issuedAt),expires=Date.parse(request.expiresAt);if(issued>now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires<now)throw new Error("request is expired");if(expires-issued>120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");}
|
||||
function post(request,options){const args=[options.bridgeCliPath,"discord-post","--cultmesh-command-id",request.requestId,"--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;}
|
||||
async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;}
|
||||
async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};}
|
||||
async function accessStoreOrWritableParent(path,mode){try{await access(path,mode);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(path),constants.W_OK);}}
|
||||
async function readAnchor(runtime,path,expectedRuntimeId){if(typeof expectedRuntimeId!=="string"||!expectedRuntimeId)throw new Error("Epiphany runtime identity is required for Persona mouth trust");const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!==expectedRuntimeId||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};}
|
||||
function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");}
|
||||
function sha(value){return createHash("sha256").update(value).digest("hex");}
|
||||
function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import {createHash,randomBytes} from "node:crypto";
|
||||
import {createSocket} from "node:dgram";
|
||||
import {createRequire} from "node:module";
|
||||
import {existsSync} from "node:fs";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {resolve} from "node:path";
|
||||
import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs";
|
||||
|
||||
export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0";
|
||||
export const permitSchema="epiphany.persona_discord_delivery_permit.v0";
|
||||
export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0";
|
||||
export const permitPurpose="epiphany.persona-discord.delivery-permit.v0";
|
||||
export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0";
|
||||
export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0";
|
||||
export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0";
|
||||
export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0";
|
||||
const connectionId=0xe91f0002;
|
||||
|
||||
export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed];
|
||||
export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed];
|
||||
export const permitRequestWireTuple=v=>[...permitRequestTuple(v),v.requesterSignature];
|
||||
export function parsePermitWireTuple(v){if(!Array.isArray(v)||v.length!==15)throw new Error("Epiphany permit must be an exact Rust wire tuple");const [schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature]=v;return {schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature};}
|
||||
|
||||
export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){
|
||||
const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now();
|
||||
const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()};
|
||||
request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request))));
|
||||
const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10});
|
||||
try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(permitRequestWireTuple(request)),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();}
|
||||
}
|
||||
|
||||
export function validatePermit(value,request,anchor,encode){const v=Array.isArray(value)?parsePermitWireTuple(value):value,providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expires<now||observed>issued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};}
|
||||
export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};}
|
||||
async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);}
|
||||
function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=parsePermitWireTuple(m.decode(d?.payload));if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})}
|
||||
async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}}
|
||||
function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})}
|
||||
function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}}
|
||||
function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import {createSocket} from "node:dgram";
|
||||
import {parseRequest,receiptDocumentTuple} from "./persona-discord-delivery-documents.mjs";
|
||||
|
||||
export const personaDiscordDeliveryConnectionId=0xe91f0003;
|
||||
|
||||
export async function startPersonaDiscordDeliveryRudpServer({runtime,endpoint,runtimeId="bifrost-discord-yggdrasil",onRequest,socketFactory=createSocket,connectionFactory}){
|
||||
const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");
|
||||
await bind(socket,host,port);
|
||||
const connection=(connectionFactory??(options=>new runtime.cultnet.CultNetRudpSocketTransportConnection(options)))({runtimeId,socket,mode:"server",connectionId:personaDiscordDeliveryConnectionId,initialSequence:100,resendDelayMs:50,resendPollMs:10});
|
||||
let busy=false;const diagnostics={datagrams:0,frames:0,responses:0,errors:[]};socket.on("message",()=>diagnostics.datagrams++);
|
||||
const frame=async value=>{
|
||||
diagnostics.frames++;
|
||||
if(busy)return;
|
||||
busy=true;
|
||||
try{
|
||||
if(value.channelId!=="schema")throw new Error("Persona delivery request arrived on a foreign channel");
|
||||
const message=runtime.cultnet.parseCultNetMessage(runtime.msgpack.decode(value.payload),"cultnet.schema.v0"),document=message.document;
|
||||
if(message.schemaVersion!=="cultnet.document_put_raw.v0"||document.schemaId!=="epiphany.persona_discord_delivery_request.v0"||document.payloadEncoding!=="messagepack"||document.sourceRole!=="epiphany-persona-mouth"||document.sourceRuntimeId!=="epiphany-starfire"||!Array.isArray(document.tags)||document.tags.length!==1||document.tags[0]!=="cultnet.transport.rudp.v0")throw new Error("Persona delivery request envelope substituted authority");
|
||||
const request=parseRequest(runtime.msgpack.decode(document.payload));
|
||||
if(document.recordKey!==request?.requestId||document.sourceAgentId!==request?.signerIdentityId||request?.targetRuntimeId!=="epiphany-starfire")throw new Error("Persona delivery request envelope is not bound to its signed payload");
|
||||
const receipt=await onRequest(request);
|
||||
const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:`persona-delivery-receipt-${receipt.requestId}`,document:{schemaId:"bifrost.persona_discord_delivery_receipt.v0",recordKey:receipt.requestId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(receiptDocumentTuple(receipt)),sourceRuntimeId:runtimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};
|
||||
connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));diagnostics.responses++;
|
||||
}catch(error){const message=error instanceof Error?error.message:String(error);diagnostics.errors.push(message);console.error(`Bifrost Persona delivery RUDP rejected frame: ${message}`);}finally{busy=false;}
|
||||
};
|
||||
connection.on("frame",frame);
|
||||
return {address:socket.address(),diagnostics,close(){connection.off?.("frame",frame);connection.close();}};
|
||||
}
|
||||
|
||||
function parseEndpoint(value){const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port)throw new Error("Persona delivery listener must be rudp://host:port");return {host:url.hostname,port:Number(url.port)};}
|
||||
function bind(socket,host,port){return new Promise((resolve,reject)=>{socket.once("error",reject);socket.once("listening",resolve);socket.bind(port,host);});}
|
||||
|
|
@ -8,18 +8,12 @@ import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedb
|
|||
import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs";
|
||||
import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs";
|
||||
import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs";
|
||||
import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.mjs";
|
||||
import { startPersonaDiscordDeliveryRudpServer } from "./persona-discord-rudp.mjs";
|
||||
import { buildPersonaDiscordDeliveryDefinitions, parseRequest as parsePersonaDiscordDeliveryRequest } from "./persona-discord-delivery-documents.mjs";
|
||||
|
||||
const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib"));
|
||||
const [command,...rawArgs]=process.argv.slice(2),options=parseArgs(rawArgs);
|
||||
const privateStorePath=resolve(options.store||(process.platform==="win32"?resolve(root,".bifrost","provider-store.cc"):"/var/lib/gamecult/bifrost/persona-feedback/provider.cc"));
|
||||
const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-conversation-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc"));
|
||||
const deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.cc"));
|
||||
const personaDeliveryRequestStorePath=resolve(options["persona-delivery-request-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-requests.cc"):"/srv/bifrost/persona-discord-crossing/requests.cc"));
|
||||
const personaDeliveryReceiptStorePath=resolve(options["persona-delivery-receipt-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-receipts.cc"):"/srv/bifrost/persona-discord-crossing/receipts.cc"));
|
||||
const personaDeliveryExecutionStorePath=resolve(options["persona-delivery-execution-store"]||(process.platform==="win32"?resolve(root,".bifrost","private","persona-discord-executions.cc"):"/var/lib/gamecult/bifrost/persona-feedback/persona-discord-executions.cc"));
|
||||
const runtime=loadRuntime(),defs=buildFeedbackDefinitions(runtime.defineDocumentType);
|
||||
|
||||
try{await main();}catch(error){console.error(error instanceof Error?error.message:String(error));process.exitCode=1;}
|
||||
|
|
@ -30,14 +24,11 @@ async function main(){
|
|||
if(command==="process")return processEvent(requireOption("event-id"));
|
||||
if(command==="serve")return serve();
|
||||
if(command==="serve-discord")return serveDiscord();
|
||||
if(command==="serve-persona-delivery")return servePersonaDelivery();
|
||||
if(command==="export")return exportSnapshot();
|
||||
if(command==="enroll-identity")return enrollIdentity();
|
||||
if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity();
|
||||
if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor();
|
||||
if(command==="export-provider-health-anchor")return exportProviderHealthAnchor();
|
||||
if(command==="status")return status();
|
||||
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|serve-persona-delivery|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor");
|
||||
throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|status|export|enroll-identity|export-provider-health-anchor");
|
||||
}
|
||||
async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});}
|
||||
async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});}
|
||||
|
|
@ -77,19 +68,14 @@ async function serveDiscord(){
|
|||
const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};};
|
||||
const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`);
|
||||
const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`);
|
||||
await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`));
|
||||
await publishHealth();
|
||||
const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000);
|
||||
let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms"));
|
||||
let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms"));
|
||||
try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);}
|
||||
try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);}
|
||||
}
|
||||
|
||||
async function servePersonaDelivery(){const readinessState=await readiness();if(!readinessState.personaDelivery.ready)throw new Error(`Bifrost Persona delivery is not ready: ${readinessState.personaDelivery.reasons.join("; ")}`);const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest});print({schemaVersion:"bifrost.persona_discord_delivery_listener.v0",status:"ready",runtimeId:"bifrost-discord-yggdrasil",host:server.address.address,port:server.address.port,requestSignerIdentityId:readinessState.personaDelivery.requestSignerIdentityId,receiptSignerIdentityId:readinessState.personaDelivery.receiptSignerIdentityId,privateStateExposed:false});await new Promise(()=>{});}
|
||||
|
||||
async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});}
|
||||
|
||||
async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;}
|
||||
|
||||
async function admitDiscordConversation(conversation,producerRuntimeId){
|
||||
const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.();
|
||||
|
|
@ -104,11 +90,9 @@ async function admitDiscordConversation(conversation,producerRuntimeId){
|
|||
}
|
||||
|
||||
async function enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),createdAt=new Date().toISOString(),anchor=trustAnchor(identity,createdAt),out=resolve(requireOption("trust-anchor-out")),providerOut=options["provider-health-trust-anchor-out"]?resolve(options["provider-health-trust-anchor-out"]):null;await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)));if(providerOut){if(providerOut===out)throw new Error("Feedback and provider-health trust anchors must use distinct paths.");const providerAnchor=providerHealthTrustAnchor(identity,createdAt);await mkdir(dirname(providerOut),{recursive:true});await writeFile(providerOut,runtime.encode(anchorTuple(providerAnchor)));}print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId,providerHealthTrustAnchor:providerOut??""});}
|
||||
async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));}
|
||||
async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("permit-request-private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));}
|
||||
async function exportProviderHealthAnchor(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await loadBifrostFeedbackIdentity(keyPath),out=resolve(requireOption("trust-anchor-out")),anchor=providerHealthTrustAnchor(identity,new Date().toISOString());await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)),{flag:"wx",mode:0o640});print({privateKey:keyPath,trustAnchor:out,identityId:anchor.identityId});}
|
||||
async function exportSnapshot(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const out=resolve(requireOption("out")),scratch=`${out}.scratch.cc`,cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withDocumentType(defs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(scratch)).build();for(const value of n.cache.getAll(defs.delivery))await cache.put(defs.delivery,value.admissionId,value);for(const value of n.cache.getAll(defs.receipt))await cache.put(defs.receipt,value.receiptId,value);const registry=new runtime.CultNetDocumentRegistry([runtime.defineCultNetDocumentBinding({definition:defs.delivery,payloadSchemaVersion:feedbackDeliverySchema}),runtime.defineCultNetDocumentBinding({definition:defs.receipt,payloadSchemaVersion:feedbackReceiptSchema})]),message=registry.createRawSnapshotResponse(cache,options["message-id"]||`persona-feedback-${randomUUID()}`);await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(message));await rm(scratch,{force:true});print({schemaVersion:message.schemaVersion,messageId:message.messageId,documentCount:message.documents.length,out});}
|
||||
async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};}
|
||||
async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const reasons=[...ingressReasons],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,status:ingressReady?"ready":"degraded",reasons,privateStateExposed:false};}
|
||||
async function status(){print(await readiness());}
|
||||
|
||||
function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),cn=nr("cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};}
|
||||
|
|
|
|||
303
tools/persona-matrix-mouth.mjs
Normal file
303
tools/persona-matrix-mouth.mjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// The Matrix ear and mouth of one Persona. One process per Persona.
|
||||
//
|
||||
// Ear: /sync the configured rooms; every message from another sender becomes a
|
||||
// delivery kept in memory. The /sync position (next_batch) is persisted only when
|
||||
// an ack covers it, so a restart before the ack re-delivers instead of losing.
|
||||
// Mouth: a send request becomes one PUT with txnId = request_id (a retry is
|
||||
// idempotent) and only while the instance brake is released.
|
||||
// Ring: the first delivery of an idle window starts the Persona's one-shot unit.
|
||||
// Starting is a request; the unit's own gate decides.
|
||||
//
|
||||
// The Matrix access token is read here, from a systemd credential, and nowhere
|
||||
// else. It is never put in a document, an environment passed to a child, or a log.
|
||||
//
|
||||
// Everything outside this process is a port (fetch, state, brake, ring, clock), so
|
||||
// the core runs against a fake homeserver with no CultLib and no daemon.
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { createSocket } from "node:dgram";
|
||||
import { resolve, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as docs from "./persona-chat-documents.mjs";
|
||||
|
||||
const API = "/_matrix/client/v3";
|
||||
const BRAKE_TYPE = "eureka.control.brake.v1";
|
||||
const CONTEXT = 20;
|
||||
const RING_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function createMouth({
|
||||
persona, homeserver, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, ring,
|
||||
now = Date.now, log = () => {}, syncTimeoutMs = 30000,
|
||||
}) {
|
||||
if (!persona || !homeserver || !token || !rooms?.length) throw new Error("persona, homeserver, rooms and token are required");
|
||||
const say = message => log(redact(String(message), token));
|
||||
const roomIdByAlias = new Map();
|
||||
const aliasByRoomId = new Map();
|
||||
const history = new Map(); // room_id -> last CONTEXT messages, oldest first
|
||||
const displayNames = new Map();
|
||||
let selfId = null;
|
||||
let cursor = null; // the /sync position we are reading from
|
||||
let seqCounter = 0;
|
||||
let pending = []; // deliveries not yet acked
|
||||
let batches = []; // { token, maxSeq }: positions awaiting an ack
|
||||
let committed = null; // the position that is persisted
|
||||
let lastRingAt = -Infinity;
|
||||
let started = false;
|
||||
|
||||
async function matrix(method, path, body) {
|
||||
let response;
|
||||
try {
|
||||
response = await httpFetch(`${homeserver}${API}${path}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`homeserver unreachable: ${redact(error?.message ?? String(error), token)}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`homeserver answered ${response.status} for ${method} ${path.split("?")[0]}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const saved = await state.load();
|
||||
cursor = committed = saved?.nextBatch ?? null;
|
||||
seqCounter = saved?.seq ?? 0;
|
||||
selfId = (await matrix("GET", "/account/whoami")).user_id;
|
||||
for (const alias of rooms) {
|
||||
const { room_id: roomId } = await matrix("GET", `/directory/room/${encodeURIComponent(alias)}`);
|
||||
await matrix("POST", `/join/${encodeURIComponent(roomId)}`, {});
|
||||
roomIdByAlias.set(alias, roomId);
|
||||
aliasByRoomId.set(roomId, alias);
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
|
||||
// One /sync round. Returns the deliveries it made.
|
||||
async function syncOnce() {
|
||||
if (!started) await start();
|
||||
const filter = encodeURIComponent(JSON.stringify({ room: { timeline: { limit: CONTEXT } } }));
|
||||
const since = cursor === null ? "" : `&since=${encodeURIComponent(cursor)}`;
|
||||
const reply = await matrix("GET", `/sync?timeout=${cursor === null ? 0 : syncTimeoutMs}&filter=${filter}${since}`);
|
||||
const baseline = cursor === null; // first ever sync: history is context, not mail
|
||||
const made = [];
|
||||
for (const [roomId, room] of Object.entries(reply.rooms?.join ?? {})) {
|
||||
const alias = aliasByRoomId.get(roomId);
|
||||
if (!alias) continue;
|
||||
for (const event of [...(room.state?.events ?? []), ...(room.timeline?.events ?? [])]) {
|
||||
if (event.type === "m.room.member" && event.content?.displayname) displayNames.set(event.state_key, event.content.displayname);
|
||||
}
|
||||
for (const event of room.timeline?.events ?? []) {
|
||||
if (event.type !== "m.room.message" || typeof event.content?.body !== "string") continue;
|
||||
const entry = { sender: event.sender, text: event.content.body, atUnixMs: event.origin_server_ts ?? now() };
|
||||
const context = (history.get(roomId) ?? []).slice(-CONTEXT);
|
||||
if (!baseline && event.sender !== selfId) {
|
||||
made.push({
|
||||
seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender,
|
||||
senderDisplay: displayNames.get(event.sender) ?? localpart(event.sender), text: entry.text, atUnixMs: entry.atUnixMs,
|
||||
context: context.map(c => ({ ...c })),
|
||||
});
|
||||
}
|
||||
history.set(roomId, [...context, entry].slice(-CONTEXT));
|
||||
}
|
||||
}
|
||||
const wasIdle = pending.length === 0;
|
||||
pending.push(...made);
|
||||
cursor = reply.next_batch;
|
||||
batches.push({ token: reply.next_batch, maxSeq: seqCounter });
|
||||
await commit();
|
||||
if (made.length > 0) await state.save({ nextBatch: committed, seq: seqCounter }); // sequence numbers never repeat across a restart
|
||||
if (made.length > 0 && (wasIdle || now() - lastRingAt >= RING_WINDOW_MS)) {
|
||||
lastRingAt = now();
|
||||
Promise.resolve().then(ring).catch(error => say(`ring failed: ${error?.message ?? error}`));
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
// The persisted position is the newest batch whose deliveries are all acked.
|
||||
async function commit(throughSeq = pending.length ? pending[0].seq - 1 : seqCounter) {
|
||||
let covered = null;
|
||||
for (const b of batches) { if (b.maxSeq <= throughSeq) covered = b; else break; }
|
||||
if (covered && covered.token !== committed) {
|
||||
committed = covered.token;
|
||||
await state.save({ nextBatch: committed, seq: seqCounter });
|
||||
}
|
||||
batches = batches.filter(b => b.maxSeq > throughSeq);
|
||||
}
|
||||
|
||||
async function ack(throughSeq) {
|
||||
pending = pending.filter(d => d.seq > throughSeq);
|
||||
await commit(pending.length ? pending[0].seq - 1 : seqCounter);
|
||||
}
|
||||
|
||||
function pendingAfter(afterSeq) {
|
||||
return pending.filter(d => d.seq > afterSeq).map(d => ({ ...d }));
|
||||
}
|
||||
|
||||
async function send({ requestId, persona: asked, roomAlias, text }) {
|
||||
const refuse = refusal => ({ requestId, eventId: null, refusal });
|
||||
if (asked !== persona) return refuse("PersonaMismatch");
|
||||
let released = false;
|
||||
try { released = (await brake()) === true; } catch { released = false; }
|
||||
if (!released) return refuse("Brake");
|
||||
if (typeof text !== "string" || text.trim() === "") return refuse("EmptyText");
|
||||
if (!started) await start();
|
||||
const roomId = roomIdByAlias.get(roomAlias);
|
||||
if (!roomId) return refuse("UnknownRoom");
|
||||
try {
|
||||
const sent = await matrix("PUT", `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(requestId)}`, { msgtype: "m.text", body: text });
|
||||
return { requestId, eventId: sent.event_id, refusal: null };
|
||||
} catch (error) {
|
||||
say(`send failed: ${error.message}`);
|
||||
return refuse("SendFailed");
|
||||
}
|
||||
}
|
||||
|
||||
async function run(signal) {
|
||||
while (!signal?.aborted) {
|
||||
try { await syncOnce(); } catch (error) {
|
||||
say(`sync failed: ${error.message}`);
|
||||
await new Promise(r => { const t = setTimeout(r, 5000); signal?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { start, syncOnce, ack, pendingAfter, send, run, get pendingCount() { return pending.length; } };
|
||||
}
|
||||
|
||||
export function redact(message, token) {
|
||||
return token ? message.split(token).join("[redacted]") : message;
|
||||
}
|
||||
|
||||
function localpart(userId) {
|
||||
return userId.replace(/^@/, "").split(":")[0];
|
||||
}
|
||||
|
||||
// Ring: start the Persona's one-shot unit. No retry loop, no token in its environment.
|
||||
export function createRing({ instance, run = execFile, log = () => {} }) {
|
||||
return () => new Promise(done => {
|
||||
run("systemctl", ["start", "--no-block", `mind-persona@${instance}.service`], { env: { PATH: process.env.PATH ?? "" } }, error => {
|
||||
if (error) log(`ring failed: ${error.message}`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Brake: released only when the instance's control store decodes to a released
|
||||
// brake. Absent, undecodable or held reads are all "not released".
|
||||
export function createBrakeReader({ controlPath, readStore, decode }) {
|
||||
return async () => {
|
||||
try {
|
||||
const read = readStore(controlPath);
|
||||
if (read.absent) return false;
|
||||
const envelope = read.envelopes.find(e => e.type === BRAKE_TYPE && e.key === "brake");
|
||||
if (!envelope) return false;
|
||||
const value = decode(new Uint8Array(envelope.payload));
|
||||
const released = Array.isArray(value) ? value[0] : value instanceof Map ? value.get(0) ?? value.get("released") : value?.[0] ?? value?.["0"] ?? value?.released;
|
||||
return released === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The RUDP connection id is derived from the credential, so a peer without it cannot open the session.
|
||||
export function connectionIdFor(connectionKey) {
|
||||
return createHash("sha256").update(connectionKey).digest().readUInt32BE(0);
|
||||
}
|
||||
|
||||
// CultNet RUDP endpoint on loopback. Requests arrive as document_put_raw messages.
|
||||
export async function startChatEndpoint({ mouth, persona, runtime, port, connectionKey, runtimeId, socketFactory = createSocket, host = "127.0.0.1", log = () => {} }) {
|
||||
if (!/^127\.\d+\.\d+\.\d+$/.test(host)) throw new Error("the chat endpoint listens on loopback only");
|
||||
const { msgpack, cultnet } = runtime;
|
||||
const socket = socketFactory("udp4");
|
||||
await new Promise((ok, fail) => { socket.once("error", fail); socket.once("listening", ok); socket.bind(port, host); });
|
||||
const connectionId = connectionIdFor(connectionKey);
|
||||
const connection = new cultnet.CultNetRudpSocketTransportConnection({ runtimeId, socket, mode: "server", connectionId, initialSequence: 100, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200 });
|
||||
const respond = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(cultnet.encodeCultNetMessageForWire(
|
||||
docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: runtimeId, sourceAgentId: persona, sourceRole: "bifrost-persona-matrix-mouth" }), "cultnet.schema.v0")));
|
||||
let busy = Promise.resolve();
|
||||
const handle = async value => {
|
||||
try {
|
||||
if (value.channelId !== "schema") throw new Error("chat request arrived on a foreign channel");
|
||||
const message = cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0");
|
||||
const document = docs.checkEnvelope(message, [docs.kinds.pendingRequest, docs.kinds.ack, docs.kinds.sendRequest]);
|
||||
const tuple = msgpack.decode(document.payload);
|
||||
if (document.schemaId === docs.kinds.pendingRequest) {
|
||||
const request = docs.parsePendingRequest(tuple);
|
||||
if (request.persona !== persona) throw new Error("chat request names another Persona");
|
||||
respond(docs.kinds.pending, persona, docs.pendingTuple({ persona, deliveries: mouth.pendingAfter(request.afterSeq) }));
|
||||
} else if (document.schemaId === docs.kinds.ack) {
|
||||
const request = docs.parseAck(tuple);
|
||||
if (request.persona !== persona) throw new Error("chat ack names another Persona");
|
||||
await mouth.ack(request.throughSeq);
|
||||
} else {
|
||||
const request = docs.parseSendRequest(tuple);
|
||||
const receipt = await mouth.send(request);
|
||||
respond(docs.kinds.sendReceipt, receipt.requestId, docs.sendReceiptTuple(receipt));
|
||||
}
|
||||
} catch (error) {
|
||||
log(`chat endpoint rejected a frame: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
const frame = value => { busy = busy.then(() => handle(value)); };
|
||||
connection.on("frame", frame);
|
||||
return { address: socket.address(), close() { connection.off?.("frame", frame); connection.close(); } };
|
||||
}
|
||||
|
||||
export function cultcacheState({ runtime, path }) {
|
||||
const def = runtime.defineDocumentType({
|
||||
type: "gamecult.persona.chat_cursor.v1", schemaName: "gamecult.persona.chat_cursor.v1", schemaId: "gamecult.persona.chat_cursor.v1",
|
||||
schemaVersion: "gamecult.persona.chat_cursor.v1", contentHash: "gamecult.persona.chat_cursor.v1", global: false, name: "key",
|
||||
schema: { parse: v => ({ key: String(v.key), nextBatch: v.nextBatch ?? null, seq: Number(v.seq) }) },
|
||||
});
|
||||
const cache = runtime.CultCache.builder().withDocumentType(def).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();
|
||||
const unwrap = v => (Array.isArray(v) ? v[0] : v?.value ?? v);
|
||||
return {
|
||||
async load() { await cache.pullAllBackingStores(); return unwrap(cache.get(def, "cursor")) ?? null; },
|
||||
async save({ nextBatch, seq }) { await cache.put(def, "cursor", { key: "cursor", nextBatch, seq }); },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadRuntime(cultRoot) {
|
||||
const at = name => createRequire(resolve(cultRoot, "packages", name, "package.json"));
|
||||
const cc = at("cultcache-ts")("@gamecult/cultcache-ts"), cn = at("cultnet-ts")("cultnet-ts"), mp = at("cultnet-ts")("@msgpack/msgpack");
|
||||
return { CultCache: cc.CultCache, SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, defineDocumentType: cc.defineDocumentType, readSingleFileStore: cc.readSingleFileStore, cultnet: cn, msgpack: mp };
|
||||
}
|
||||
|
||||
function required(env, name) {
|
||||
if (!env[name]) throw new Error(`${name} is required`);
|
||||
return env[name];
|
||||
}
|
||||
function credential(env, name) {
|
||||
return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim();
|
||||
}
|
||||
|
||||
export async function main(env = process.env) {
|
||||
const persona = required(env, "PERSONA");
|
||||
const instance = env.MIND_INSTANCE ?? persona; // the unit template is instanced by the Persona's mind
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(instance)) throw new Error("MIND_INSTANCE is not a slug");
|
||||
const token = credential(env, "matrix-token");
|
||||
const runtime = loadRuntime(resolve(env.CULTLIB_ROOT ?? resolve(import.meta.dirname, "..", "..", "CultLib")));
|
||||
const log = line => console.error(line);
|
||||
const controlPath = join(env.CONTROL_ROOT ?? "/etc/gamecult/minds", instance, "control.cc");
|
||||
const mouth = createMouth({
|
||||
persona, token, log,
|
||||
homeserver: required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""),
|
||||
rooms: required(env, "MATRIX_ROOMS").split(",").map(s => s.trim()).filter(Boolean),
|
||||
state: cultcacheState({ runtime, path: env.STATE_PATH ?? join(required(env, "STATE_DIRECTORY"), "cursor.cc") }),
|
||||
brake: createBrakeReader({ controlPath, readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }),
|
||||
ring: createRing({ instance, log }),
|
||||
});
|
||||
await mouth.start();
|
||||
const listen = required(env, "CHAT_RUDP_LISTEN"); // host:port, loopback
|
||||
const colon = listen.lastIndexOf(":");
|
||||
await startChatEndpoint({ mouth, persona, runtime, host: listen.slice(0, colon), port: Number(listen.slice(colon + 1)), connectionKey: credential(env, "chat-key"), runtimeId: `bifrost-persona-mouth-${persona}`, log });
|
||||
await mouth.run();
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch(error => { console.error(error?.message ?? String(error)); process.exitCode = 1; });
|
||||
}
|
||||
Loading…
Reference in a new issue