2855 lines
94 KiB
TypeScript
2855 lines
94 KiB
TypeScript
import "dotenv/config";
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { spawnSync } from "node:child_process";
|
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
|
import { loadConfig } from "@voidbot/config";
|
|
import {
|
|
applyRepoFacePostFatigueAfterSpeech,
|
|
type AuditLog,
|
|
buildVoidMcpServerConfig,
|
|
createStateStorage,
|
|
applyVoidSelfStateOperation,
|
|
findRepoDiscordIdentity,
|
|
faceRegistryAsRepoDiscordRegistry,
|
|
isRepoDiscordIdentityAllowedInChannel,
|
|
type JobQueue,
|
|
loadFaceIdentityRegistry,
|
|
loadSystemMessageCatalog,
|
|
resolveRepoFaceStatePath,
|
|
type SystemMessageCatalog,
|
|
} from "@voidbot/core";
|
|
import {
|
|
LocalLlmProvider,
|
|
OpenAiApiProvider,
|
|
OwnerCodexProvider,
|
|
ProviderRegistry,
|
|
} from "@voidbot/providers";
|
|
import {
|
|
filterPromptEchoHistoryResults,
|
|
createTextEmbedder,
|
|
createVectorStores,
|
|
FileMessageArchiveRepository,
|
|
FileSourceDocumentArchiveRepository,
|
|
RetrievalService,
|
|
searchHistoryWithArchiveFallback,
|
|
SourceDocumentIngester,
|
|
type ArchivedMessageRecord,
|
|
} from "@voidbot/rag";
|
|
import {
|
|
type JobRecord,
|
|
type ProviderAdapter,
|
|
type ProviderArtifact,
|
|
type ProviderNotificationIntent,
|
|
type ProviderResponse,
|
|
loadPromptTemplate,
|
|
} from "@voidbot/shared";
|
|
import {
|
|
type RepoIdentityArticleIntent,
|
|
isValidArticleDate,
|
|
normalizeArticlePath,
|
|
normalizeArticleSite,
|
|
renderRepoIdentityArticleMarkdown,
|
|
resolveArticleRepoRoot,
|
|
validateRenderedArticleMarkdown,
|
|
} from "./repo-face-article.js";
|
|
import {
|
|
REPO_IDENTITY_POST_SENTINEL,
|
|
isNonPublicRepoIdentitySpeech,
|
|
normalizePublicRepoIdentitySpeech,
|
|
parseRepoIdentityPostIntents,
|
|
type RepoIdentityPostIntent,
|
|
} from "./repo-face-speech.js";
|
|
|
|
const config = loadConfig();
|
|
const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:";
|
|
const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:";
|
|
const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:";
|
|
const REPO_IDENTITY_BIFROST_TOPIC_SENTINEL = "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC:";
|
|
const CURRENT_REPO_FACE_IDENTITY_SELECTORS = new Set([
|
|
"face_id",
|
|
"faceid",
|
|
"current_face",
|
|
"current_face_id",
|
|
"currentface",
|
|
"currentfaceid",
|
|
"current_identity",
|
|
"current_identity_id",
|
|
"currentidentity",
|
|
"currentidentityid",
|
|
"job_identity",
|
|
"job_identity_id",
|
|
"jobidentity",
|
|
"jobidentityid",
|
|
]);
|
|
const REPO_FACE_FORBIDDEN_CHILD_TOOLS = new Set([
|
|
"read_repo_face_state",
|
|
"list_mcp_resources",
|
|
"read_mcp_resource",
|
|
"post_repo_identity_message",
|
|
"apply_repo_face_state_operation",
|
|
"notify_owner",
|
|
]);
|
|
|
|
const embedder = createTextEmbedder({
|
|
backend: config.ragEmbeddingBackend,
|
|
hashDimensions: config.ragEmbeddingDimensions,
|
|
ollamaBaseUrl: config.ragOllamaBaseUrl,
|
|
ollamaModel: config.ragOllamaModel,
|
|
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
|
|
queryInstruction: config.ragQueryInstruction,
|
|
});
|
|
const sourceQueryEmbedder = createTextEmbedder({
|
|
backend: config.ragEmbeddingBackend,
|
|
hashDimensions: config.ragEmbeddingDimensions,
|
|
ollamaBaseUrl: config.ragOllamaBaseUrl,
|
|
ollamaModel: config.ragOllamaModel,
|
|
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
|
|
queryInstruction: config.ragSourceQueryInstruction,
|
|
});
|
|
const vectorStores = createVectorStores({
|
|
kind: config.vectorStore.kind,
|
|
historyPath: config.vectorStore.path,
|
|
personaMemoryPath: config.vectorStore.personaMemoryPath,
|
|
sourceRoot: config.sourceVectorStoreRoot,
|
|
qdrant: config.qdrant,
|
|
historyEmbedder: embedder,
|
|
sourceEmbedder: sourceQueryEmbedder,
|
|
personaMemoryEmbedder: embedder,
|
|
});
|
|
const historyVectorStore = vectorStores.history;
|
|
const sourceVectorStore = vectorStores.source;
|
|
const personaMemoryVectorStore = vectorStores.personaMemory;
|
|
const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath);
|
|
const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(config.ragSourceArchivePath);
|
|
const sourceDocumentIngester = new SourceDocumentIngester();
|
|
const retrievalService = new RetrievalService(
|
|
historyVectorStore,
|
|
sourceVectorStore,
|
|
personaMemoryVectorStore,
|
|
);
|
|
|
|
let isProcessing = false;
|
|
let providerRegistry: ProviderRegistry;
|
|
let systemMessages: SystemMessageCatalog;
|
|
let jobQueue: JobQueue;
|
|
let auditLog: AuditLog;
|
|
|
|
void main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
async function main(): Promise<void> {
|
|
const {
|
|
jobQueue: stateJobQueue,
|
|
auditLog: stateAuditLog,
|
|
} = await createStateStorage({
|
|
backend: config.stateStorageBackend,
|
|
databaseDsn: config.databaseDsn,
|
|
jobsFile: config.jobsFile,
|
|
auditLogFile: config.auditLogFile,
|
|
interactionMemoryFile: config.interactionMemoryFile,
|
|
rateLimitStateFile: config.rateLimitStateFile,
|
|
});
|
|
jobQueue = stateJobQueue;
|
|
auditLog = stateAuditLog;
|
|
systemMessages = await loadSystemMessageCatalog(
|
|
config.systemMessagesPath,
|
|
resolve("config/system-messages.json"),
|
|
);
|
|
providerRegistry = await buildProviderRegistry(systemMessages);
|
|
console.log(`VoidBot worker polling every ${config.workerPollIntervalMs}ms.`);
|
|
await pollPendingJobs();
|
|
setInterval(() => {
|
|
void pollPendingJobs();
|
|
}, config.workerPollIntervalMs);
|
|
}
|
|
|
|
async function processPendingJobs(): Promise<void> {
|
|
if (isProcessing) {
|
|
return;
|
|
}
|
|
|
|
isProcessing = true;
|
|
|
|
try {
|
|
systemMessages = await loadSystemMessageCatalog(
|
|
config.systemMessagesPath,
|
|
resolve("config/system-messages.json"),
|
|
);
|
|
providerRegistry = await buildProviderRegistry(systemMessages);
|
|
const jobs = await jobQueue.claimRunnableJobs();
|
|
|
|
for (const job of jobs) {
|
|
await processJob(job);
|
|
}
|
|
} finally {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
async function pollPendingJobs(): Promise<void> {
|
|
try {
|
|
await processPendingJobs();
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? `${error.name}: ${error.message}` : "Unexpected worker poll failure.";
|
|
console.error(`Worker poll failed: ${message}`);
|
|
}
|
|
}
|
|
|
|
async function buildProviderRegistry(systemMessagesCatalog: SystemMessageCatalog): Promise<ProviderRegistry> {
|
|
return new ProviderRegistry([
|
|
new OwnerCodexProvider({
|
|
ownerDiscordId: config.ownerDiscordId,
|
|
enabled: config.enabledProviders.includes("owner_codex"),
|
|
mode: config.ownerCodexMode,
|
|
executable: config.codexExecutable,
|
|
executableArgs: config.codexExecArgs,
|
|
model: config.codexModel,
|
|
reasoningEffort: config.codexModelReasoningEffort,
|
|
timeoutMs: config.codexExecTimeoutMs,
|
|
workingDirectory: process.cwd(),
|
|
historyLookup: retrievalService,
|
|
handoffNoticeBuilder: (jobId) =>
|
|
systemMessagesCatalog.render("owner_codex.handoff_notice", {
|
|
handoffPath: `.voidbot/artifacts/${jobId}/handoff.md`,
|
|
debugPath: `.voidbot/artifacts/${jobId}/debug-trace.md`,
|
|
}),
|
|
mcpServers: [buildVoidMcpServerConfig(process.cwd())],
|
|
}),
|
|
new OpenAiApiProvider(config.enabledProviders.includes("openai_api")),
|
|
new LocalLlmProvider({
|
|
enabled: config.enabledProviders.includes("local_llm"),
|
|
ownerDiscordId: config.ownerDiscordId,
|
|
ollamaBaseUrl: config.localLlm.ollamaBaseUrl,
|
|
ollamaModel: config.localLlm.ollamaModel,
|
|
ollamaTimeoutMs: config.localLlm.ollamaTimeoutMs,
|
|
ollamaKeepAlive: config.localLlm.ollamaKeepAlive,
|
|
ollamaThink: config.localLlm.ollamaThink,
|
|
ollamaNumCtx: config.localLlm.ollamaNumCtx,
|
|
allowPublicResponses: config.localLlm.allowPublic,
|
|
toolbox: {
|
|
listIndexedRepos: async () => {
|
|
const repos = await sourceArchiveRepository.listRepoSummaries();
|
|
|
|
return {
|
|
repoCount: repos.length,
|
|
repos,
|
|
};
|
|
},
|
|
searchHistory: async (input) => {
|
|
const rawResults = await searchHistoryWithArchiveFallback({
|
|
retrievalService,
|
|
archiveRepository,
|
|
query: input.query,
|
|
limit: input.limit,
|
|
guildId: input.guildId,
|
|
channelId: input.channelId,
|
|
authorId: input.authorId,
|
|
preserveOverfetch: true,
|
|
});
|
|
const results = filterPromptEchoHistoryResults(rawResults, input.query);
|
|
|
|
return {
|
|
query: input.query,
|
|
resultCount: results.length,
|
|
results: formatHistoryResults(results),
|
|
};
|
|
},
|
|
getMessageContext: async (input) => {
|
|
const messages = await archiveRepository.listContextWindow(
|
|
input.messageId,
|
|
input.before,
|
|
input.after,
|
|
);
|
|
|
|
return {
|
|
found: messages.length > 0,
|
|
messageId: input.messageId,
|
|
count: messages.length,
|
|
messages: messages.map((message) =>
|
|
formatArchivedMessageContext(message, input.messageId),
|
|
),
|
|
};
|
|
},
|
|
searchSources: async (input) => {
|
|
const results = await retrievalService.searchRepositorySources(input.query, input.limit, {
|
|
repoName: input.repoName,
|
|
pathPrefix: input.pathPrefix,
|
|
language: input.language,
|
|
});
|
|
|
|
return {
|
|
query: input.query,
|
|
resultCount: results.length,
|
|
results: formatSourceResults(results),
|
|
};
|
|
},
|
|
getSourceContext: async (input) => {
|
|
const document = await sourceArchiveRepository.get(input.sourceId);
|
|
|
|
if (!document) {
|
|
return {
|
|
found: false,
|
|
sourceId: input.sourceId,
|
|
count: 0,
|
|
chunks: [],
|
|
};
|
|
}
|
|
|
|
const chunks = sourceDocumentIngester.buildContextWindow(
|
|
document,
|
|
input.chunkIndex,
|
|
input.before,
|
|
input.after,
|
|
);
|
|
|
|
return {
|
|
found: true,
|
|
sourceId: input.sourceId,
|
|
repoName: document.repoName,
|
|
path: document.path,
|
|
language: document.language,
|
|
count: chunks.length,
|
|
chunks: chunks.map((chunk) => ({
|
|
chunkId: chunk.chunkId,
|
|
chunkIndex: chunk.chunkIndex,
|
|
lineStart: chunk.lineStart,
|
|
lineEnd: chunk.lineEnd,
|
|
isAnchor: chunk.chunkIndex === input.chunkIndex,
|
|
text: chunk.text,
|
|
})),
|
|
};
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
}
|
|
|
|
async function processJob(job: JobRecord): Promise<void> {
|
|
const provider = providerRegistry.get(job.provider);
|
|
|
|
if (!provider || !provider.isEnabled()) {
|
|
const errorMessage = `Provider ${job.provider} is not enabled.`;
|
|
await jobQueue.markFailed(job.id, errorMessage);
|
|
await maybeNotifyOwnerOfJobFailure(job, errorMessage);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = job.command === "repo-face-rumination"
|
|
? await executeRepoFaceJobWithInterpreter(provider, job)
|
|
: await executeProviderForJob(provider, job, job.contextBundle);
|
|
const artifactPaths = await writeArtifacts(job.id, response.artifacts ?? []);
|
|
|
|
if (response.status === "ready_for_review") {
|
|
await jobQueue.markAwaitingPostApproval(job.id, artifactPaths, response.summary);
|
|
await deliverOwnerNotifications(job, response.notifications ?? []);
|
|
await auditLog.record({
|
|
type: "provider.bundle_ready",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
artifactPaths,
|
|
},
|
|
});
|
|
console.log(`Prepared manual package for job ${job.id}.`);
|
|
return;
|
|
}
|
|
|
|
const rawFinalResponse = response.outputText ?? response.summary;
|
|
const finalResponse = fitDiscordMessage(rawFinalResponse);
|
|
|
|
if (job.postApprovalRequired) {
|
|
await jobQueue.markAwaitingPostApproval(job.id, artifactPaths, response.summary);
|
|
await deliverOwnerNotifications(job, response.notifications ?? []);
|
|
await auditLog.record({
|
|
type: "provider.completed_pending_post_approval",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
summary: response.summary,
|
|
},
|
|
});
|
|
console.log(`Prepared response for post approval on job ${job.id}.`);
|
|
return;
|
|
}
|
|
|
|
if (job.command === "repo-face-rumination") {
|
|
const repoFaceOutput = rawFinalResponse;
|
|
const repoIdentityPosts = parseRepoIdentityPostIntents(repoFaceOutput);
|
|
const repoIdentityStateNotes = parseRepoIdentityStateNoteIntents(repoFaceOutput);
|
|
const repoIdentityBifrostTopics = config.repoFaceBifrostEnabled
|
|
? parseRepoIdentityBifrostTopicIntents(repoFaceOutput)
|
|
: [];
|
|
const repoIdentityUpdateRequests = config.repoFaceBifrostEnabled
|
|
? parseRepoIdentityUpdateRequestIntents(repoFaceOutput)
|
|
: [];
|
|
const repoIdentityArticles = config.repoFaceGithubActionsEnabled
|
|
? parseRepoFaceArticleIntents(repoFaceOutput)
|
|
: [];
|
|
const repoIdentityProposals = config.repoFaceGithubActionsEnabled
|
|
? parseRepoIdentityProposalPrIntents(repoFaceOutput)
|
|
: [];
|
|
const repoIdentityPrComments = config.repoFaceGithubActionsEnabled
|
|
? parseRepoIdentityPrCommentIntents(repoFaceOutput)
|
|
: [];
|
|
const ignoredGithubActionIntents = config.repoFaceGithubActionsEnabled
|
|
? 0
|
|
: countRepoIdentityGithubActionIntents(repoFaceOutput);
|
|
const proposalPrSubmitted = repoIdentityProposals.length > 0;
|
|
const prCommentSubmitted = !proposalPrSubmitted && repoIdentityPrComments.length > 0;
|
|
const articlePrSubmitted =
|
|
!proposalPrSubmitted && !prCommentSubmitted && repoIdentityArticles.length > 0;
|
|
const bifrostTopicSubmitted =
|
|
config.repoFaceBifrostEnabled &&
|
|
!proposalPrSubmitted &&
|
|
!prCommentSubmitted &&
|
|
!articlePrSubmitted &&
|
|
repoIdentityBifrostTopics.length > 0;
|
|
const updateRequestRoutedToBifrostTopic =
|
|
config.repoFaceBifrostEnabled &&
|
|
!proposalPrSubmitted &&
|
|
!prCommentSubmitted &&
|
|
!articlePrSubmitted &&
|
|
!bifrostTopicSubmitted &&
|
|
repoIdentityUpdateRequests.length > 0;
|
|
console.log(
|
|
`Repo-face job ${job.id} parsed actions: say=${repoIdentityPosts.length}, stateNote=${repoIdentityStateNotes.length}, bifrostTopic=${repoIdentityBifrostTopics.length}, updateRequest=${repoIdentityUpdateRequests.length}, article=${repoIdentityArticles.length}, proposalPr=${repoIdentityProposals.length}, prComment=${repoIdentityPrComments.length}.`,
|
|
);
|
|
for (const stateNote of repoIdentityStateNotes.slice(0, 4)) {
|
|
await applyRepoIdentityStateNoteIntent(job, stateNote);
|
|
}
|
|
if (repoIdentityProposals.length > 0) {
|
|
await writeRepoIdentityProposalPrIntent(job, repoIdentityProposals[0]);
|
|
} else if (repoIdentityPrComments.length > 0) {
|
|
await commentRepoIdentityPullRequestIntent(job, repoIdentityPrComments[0]);
|
|
} else if (repoIdentityArticles.length > 0) {
|
|
await writeRepoIdentityArticleIntent(job, repoIdentityArticles[0]);
|
|
} else if (repoIdentityBifrostTopics.length > 0) {
|
|
await submitRepoIdentityBifrostTopicIntent(job, repoIdentityBifrostTopics[0]);
|
|
} else if (repoIdentityUpdateRequests.length > 0) {
|
|
await submitRepoIdentityBifrostTopicIntent(
|
|
job,
|
|
repoIdentityUpdateRequestToBifrostTopic(repoIdentityUpdateRequests[0]),
|
|
);
|
|
}
|
|
let repoIdentityPostDelivered = 0;
|
|
if (!proposalPrSubmitted && !prCommentSubmitted && !articlePrSubmitted) {
|
|
for (const post of repoIdentityPosts.slice(0, 1)) {
|
|
if (await postRepoIdentityIntent(job, post)) {
|
|
repoIdentityPostDelivered += 1;
|
|
}
|
|
}
|
|
}
|
|
const cleanedFinalResponse = stripRepoIdentityPostIntents(repoFaceOutput);
|
|
await jobQueue.completeJobDirect(job.id, cleanedFinalResponse || repoFaceOutput);
|
|
await deliverOwnerNotifications(job, response.notifications ?? []);
|
|
await auditLog.record({
|
|
type: "provider.completed",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
summary: response.summary,
|
|
autoPosted:
|
|
repoIdentityPostDelivered > 0 ||
|
|
proposalPrSubmitted ||
|
|
prCommentSubmitted ||
|
|
articlePrSubmitted ||
|
|
bifrostTopicSubmitted ||
|
|
updateRequestRoutedToBifrostTopic,
|
|
articlePrSubmitted,
|
|
proposalPrSubmitted,
|
|
prCommentSubmitted,
|
|
bifrostTopicSubmitted,
|
|
updateRequestRoutedToBifrostTopic,
|
|
ignoredGithubActionIntents,
|
|
reason:
|
|
proposalPrSubmitted
|
|
? "repo_face_rumination_submitted_registered_identity_proposal_pr"
|
|
: prCommentSubmitted
|
|
? "repo_face_rumination_commented_on_pr_as_registered_identity"
|
|
: articlePrSubmitted
|
|
? "repo_face_rumination_submitted_registered_identity_article_pr"
|
|
: bifrostTopicSubmitted
|
|
? "repo_face_rumination_submitted_bifrost_topic"
|
|
: updateRequestRoutedToBifrostTopic
|
|
? "repo_face_rumination_routed_legacy_update_request_to_bifrost_topic"
|
|
: repoIdentityPostDelivered > 0
|
|
? "repo_face_rumination_posted_as_registered_identity"
|
|
: repoIdentityPosts.length > 0
|
|
? "repo_face_rumination_speech_rejected_by_parent_interpreter"
|
|
: `${job.command}_private_summary`,
|
|
},
|
|
});
|
|
console.log(`Completed ${job.command} job ${job.id}.`);
|
|
return;
|
|
}
|
|
|
|
await postJobResponse(job, finalResponse);
|
|
await jobQueue.completeJobDirect(job.id, finalResponse);
|
|
await deliverOwnerNotifications(job, response.notifications ?? []);
|
|
await auditLog.record({
|
|
type: "provider.completed",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
summary: response.summary,
|
|
autoPosted: true,
|
|
},
|
|
});
|
|
console.log(`Completed job ${job.id}.`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unexpected worker failure.";
|
|
await jobQueue.markFailed(job.id, message);
|
|
await maybeNotifyOwnerOfJobFailure(job, message);
|
|
await auditLog.record({
|
|
type: "provider.failed",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
error: message,
|
|
},
|
|
});
|
|
console.error(`Failed job ${job.id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
async function executeProviderForJob(
|
|
provider: ProviderAdapter,
|
|
job: JobRecord,
|
|
contextBundle: JobRecord["contextBundle"],
|
|
role: "face" | "interpreter" = "face",
|
|
): Promise<ProviderResponse> {
|
|
const request = provider.buildRequest(contextBundle, {
|
|
jobId: job.id,
|
|
command: job.command,
|
|
...repoFaceHeartbeatCodexOptions(job, role),
|
|
});
|
|
return provider.execute(request);
|
|
}
|
|
|
|
async function executeRepoFaceJobWithInterpreter(
|
|
provider: ProviderAdapter,
|
|
job: JobRecord,
|
|
): Promise<ProviderResponse> {
|
|
const firstResponse = await executeProviderForJob(provider, job, job.contextBundle);
|
|
if (firstResponse.status !== "completed") {
|
|
return firstResponse;
|
|
}
|
|
|
|
const firstText = normalizeModelText(firstResponse.outputText ?? firstResponse.summary);
|
|
const firstForbiddenTools = collectForbiddenRepoFaceChildTools(firstResponse.artifacts ?? []);
|
|
const firstInterpretation = firstForbiddenTools.length > 0
|
|
? {
|
|
decision: "retry" as const,
|
|
reasons: [
|
|
`Face used forbidden substrate/tool-discovery tool(s): ${firstForbiddenTools.join(", ")}`,
|
|
"Face turns may use retrieval tools only; state and substrate inventory belong to the parent/interpreter.",
|
|
],
|
|
}
|
|
: await interpretRepoFaceTurnOutput(provider, job, firstText, {
|
|
attempt: 1,
|
|
});
|
|
if (firstInterpretation.decision === "route") {
|
|
return routeRepoFaceInterpretedOutput(firstResponse, firstInterpretation, {
|
|
job,
|
|
faceOutputText: firstText,
|
|
});
|
|
}
|
|
|
|
if (firstInterpretation.decision === "drop") {
|
|
return dropRepoFaceActionBlocks(firstResponse, firstInterpretation);
|
|
}
|
|
|
|
await auditLog.record({
|
|
type: "repo_face.parent_interpreter_retry",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
decision: firstInterpretation.decision,
|
|
reasons: firstInterpretation.reasons,
|
|
},
|
|
});
|
|
console.warn(
|
|
`Repo Face parent interpreter retrying job ${job.id}: ${firstInterpretation.reasons.join("; ")}`,
|
|
);
|
|
|
|
const retryPrompt = loadPromptTemplate("repo-face-turn-interpreter-retry.prompt.md", {
|
|
originalPrompt: job.contextBundle.prompt,
|
|
reasons: firstInterpretation.reasons,
|
|
});
|
|
const retryContext = {
|
|
...job.contextBundle,
|
|
prompt: retryPrompt,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
const retryResponse = await executeProviderForJob(provider, job, retryContext);
|
|
if (retryResponse.status !== "completed") {
|
|
return retryResponse;
|
|
}
|
|
|
|
const retryText = normalizeModelText(retryResponse.outputText ?? retryResponse.summary);
|
|
const retryForbiddenTools = collectForbiddenRepoFaceChildTools(retryResponse.artifacts ?? []);
|
|
const retryInterpretation = retryForbiddenTools.length > 0
|
|
? {
|
|
decision: "drop" as const,
|
|
reasons: [
|
|
`Face used forbidden substrate/tool-discovery tool(s) on retry: ${retryForbiddenTools.join(", ")}`,
|
|
"Dropping action blocks rather than routing a turn that crossed the child/tool boundary.",
|
|
],
|
|
}
|
|
: await interpretRepoFaceTurnOutput(provider, job, retryText, {
|
|
attempt: 2,
|
|
});
|
|
if (retryInterpretation.decision === "route") {
|
|
return routeRepoFaceInterpretedOutput(retryResponse, retryInterpretation, {
|
|
job,
|
|
faceOutputText: retryText,
|
|
});
|
|
}
|
|
|
|
await auditLog.record({
|
|
type: "repo_face.parent_interpreter_drop",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
decision: retryInterpretation.decision,
|
|
reasons: retryInterpretation.reasons,
|
|
},
|
|
});
|
|
console.warn(
|
|
`Repo Face parent interpreter dropped job ${job.id} action blocks: ${retryInterpretation.reasons.join("; ")}`,
|
|
);
|
|
return dropRepoFaceActionBlocks(retryResponse, retryInterpretation);
|
|
}
|
|
|
|
interface RepoFaceParentInterpretation {
|
|
decision: "route" | "retry" | "drop";
|
|
reasons: string[];
|
|
routedOutput?: string;
|
|
}
|
|
|
|
async function interpretRepoFaceTurnOutput(
|
|
provider: ProviderAdapter,
|
|
job: JobRecord,
|
|
outputText: string,
|
|
input: { attempt: 1 | 2 },
|
|
): Promise<RepoFaceParentInterpretation> {
|
|
const dynamicMemoryRecall = await renderRepoFaceDynamicMemoryRecall(job, outputText);
|
|
const interpreterPrompt = loadPromptTemplate("repo-face-turn-interpreter.prompt.md", {
|
|
attempt: input.attempt,
|
|
facePrompt: renderRepoFaceInterpreterPromptContext(job.contextBundle.prompt),
|
|
faceOutput: outputText.slice(0, 8000),
|
|
dynamicMemoryRecall,
|
|
});
|
|
const interpreterContext = {
|
|
...job.contextBundle,
|
|
prompt: interpreterPrompt,
|
|
retrieval: [],
|
|
voidSelfState: undefined,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const response = await executeProviderForJob(provider, job, interpreterContext, "interpreter");
|
|
const interpretationText = normalizeModelText(response.outputText ?? response.summary);
|
|
return parseRepoFaceParentInterpretation(interpretationText, input.attempt);
|
|
}
|
|
|
|
async function renderRepoFaceDynamicMemoryRecall(
|
|
job: JobRecord,
|
|
outputText: string,
|
|
): Promise<string> {
|
|
const identityId = parseRepoIdentityIdFromRequestMessageId(job.requestMessageId)
|
|
?? parseRepoIdentityIdFromPrompt(job.prompt);
|
|
if (!identityId) {
|
|
return "- Dynamic self-memory recall skipped: no Face identity could be resolved for this job.";
|
|
}
|
|
const query = [
|
|
`Current train of thought from ${identityId}:`,
|
|
outputText.slice(0, 6000),
|
|
"Original turn pressure:",
|
|
extractSemanticRecallSeedFromPrompt(job.contextBundle.prompt),
|
|
].join("\n");
|
|
try {
|
|
const results = await retrievalService.searchPersonaMemory(query, 12, {
|
|
identityId,
|
|
});
|
|
if (results.length === 0) {
|
|
return "- Dynamic self-memory recall ran, but no nearby Persona memories were found.";
|
|
}
|
|
return [
|
|
"These are derived semantic hits from the Face's own typed memory, queried from the current Face output. They are hints for interpretation and state carry-forward; `.cc` remains the owner.",
|
|
...results.map((result, index) => {
|
|
const kind = result.metadata.memoryKind ? `/${result.metadata.memoryKind}` : "";
|
|
const target = result.metadata.targetLabel ?? result.metadata.targetId ?? "unknown target";
|
|
return `- ${index + 1}. ${target}${kind} score=${result.score.toFixed(3)}: ${collapseWhitespace(result.text, 560)}`;
|
|
}),
|
|
].join("\n");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return [
|
|
"Dynamic self-memory recall unavailable:",
|
|
`- ${collapseWhitespace(message, 320)}`,
|
|
"- Do not pretend dynamic recall was available; interpret from the original prompt and Face output only.",
|
|
].join("\n");
|
|
}
|
|
}
|
|
|
|
function extractSemanticRecallSeedFromPrompt(prompt: string): string {
|
|
const markers = [
|
|
"Semantic Persona memory recall:",
|
|
"Fresh projected state for this turn:",
|
|
"Recent conversation transcript:",
|
|
];
|
|
const sections: string[] = [];
|
|
for (const marker of markers) {
|
|
const start = prompt.indexOf(marker);
|
|
if (start < 0) {
|
|
continue;
|
|
}
|
|
const nextStarts = markers
|
|
.map((candidate) => candidate === marker ? -1 : prompt.indexOf(candidate, start + marker.length))
|
|
.filter((index) => index > start)
|
|
.sort((left, right) => left - right);
|
|
const end = nextStarts[0] ?? Math.min(prompt.length, start + 2400);
|
|
sections.push(prompt.slice(start, Math.min(end, start + 2400)));
|
|
}
|
|
return sections.join("\n\n").slice(0, 5000);
|
|
}
|
|
|
|
function collapseWhitespace(input: string, maxLength = 1000): string {
|
|
const collapsed = input.replace(/\s+/g, " ").trim();
|
|
return collapsed.length > maxLength ? `${collapsed.slice(0, Math.max(0, maxLength - 3))}...` : collapsed;
|
|
}
|
|
|
|
function normalizeModelText(content: string): string {
|
|
return content.trim();
|
|
}
|
|
|
|
function renderRepoFaceInterpreterPromptContext(prompt: string): string {
|
|
if (prompt.length <= 28000) {
|
|
return prompt;
|
|
}
|
|
|
|
return [
|
|
prompt.slice(0, 6000),
|
|
"\n\n[... middle of Face prompt omitted for Interpreter context budget ...]\n\n",
|
|
prompt.slice(-22000),
|
|
].join("");
|
|
}
|
|
|
|
function parseRepoFaceParentInterpretation(
|
|
interpretationText: string,
|
|
attempt: 1 | 2,
|
|
): RepoFaceParentInterpretation {
|
|
const block = parseInterpretationBlock(interpretationText);
|
|
const decision = block.decision?.trim().toLowerCase();
|
|
const parsedDecision =
|
|
decision === "route" || decision === "retry" || decision === "drop"
|
|
? decision
|
|
: attempt === 1
|
|
? "retry"
|
|
: "drop";
|
|
const reasons = (block.reason ?? block.reasons ?? "")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.replace(/^[-*]\s*/, "").trim())
|
|
.filter(Boolean)
|
|
.slice(0, 4);
|
|
|
|
return {
|
|
decision: parsedDecision,
|
|
reasons: reasons.length > 0 ? reasons : ["parent interpreter did not provide a parseable reason"],
|
|
routedOutput: extractRoutedRepoFaceOutput(interpretationText),
|
|
};
|
|
}
|
|
|
|
function collectForbiddenRepoFaceChildTools(artifacts: ProviderArtifact[]): string[] {
|
|
const forbidden = new Set<string>();
|
|
for (const artifact of artifacts) {
|
|
if (!/^codex-turn-\d+-trace\.json$/.test(artifact.name)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(artifact.content) as {
|
|
events?: Array<{ kind?: string; tool?: string }>;
|
|
};
|
|
for (const event of parsed.events ?? []) {
|
|
if (
|
|
event.kind === "mcp_tool_started" &&
|
|
typeof event.tool === "string" &&
|
|
REPO_FACE_FORBIDDEN_CHILD_TOOLS.has(event.tool)
|
|
) {
|
|
forbidden.add(event.tool);
|
|
}
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return [...forbidden].sort();
|
|
}
|
|
|
|
function extractRoutedRepoFaceOutput(reviewText: string): string | undefined {
|
|
const lines = reviewText.split(/\r?\n/);
|
|
const end = lines.findIndex((line) => line.trim().toUpperCase() === "END");
|
|
if (end < 0) {
|
|
return undefined;
|
|
}
|
|
const routed = lines.slice(end + 1).join("\n").trim();
|
|
return routed.length > 0 ? routed : undefined;
|
|
}
|
|
|
|
function routeRepoFaceInterpretedOutput(
|
|
response: ProviderResponse,
|
|
interpretation: RepoFaceParentInterpretation,
|
|
input: { job: JobRecord; faceOutputText: string },
|
|
): ProviderResponse {
|
|
const rawOutputText = interpretation.routedOutput?.trim() ?? "";
|
|
const outputText = normalizeInterpretedRepoFaceSpeechDestinations(rawOutputText, input);
|
|
const summary = outputText || "Repo Face parent interpreter routed no public or governed action.";
|
|
return {
|
|
...response,
|
|
outputText: summary,
|
|
summary,
|
|
metadata: {
|
|
...(response.metadata ?? {}),
|
|
repoFaceParentInterpreterDecision: interpretation.decision,
|
|
repoFaceParentInterpreterReasons: interpretation.reasons.join(" | "),
|
|
repoFaceParentInterpretedOutput: interpretation.routedOutput ? "true" : "false",
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeInterpretedRepoFaceSpeechDestinations(
|
|
outputText: string,
|
|
input: { job: JobRecord; faceOutputText: string },
|
|
): string {
|
|
if (input.job.command !== "repo-face-rumination") {
|
|
return outputText;
|
|
}
|
|
if (repoFaceOutputHasExplicitSayChannel(input.faceOutputText)) {
|
|
return outputText;
|
|
}
|
|
if (!input.job.guildContext?.channelId) {
|
|
return outputText;
|
|
}
|
|
|
|
return rewriteRepoFaceSayChannels(outputText, "current_room");
|
|
}
|
|
|
|
function repoFaceOutputHasExplicitSayChannel(outputText: string): boolean {
|
|
return parseRepoFaceActionBlocks(outputText).some((block) =>
|
|
block.kind === "say" &&
|
|
Boolean(optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId))
|
|
);
|
|
}
|
|
|
|
function rewriteRepoFaceSayChannels(outputText: string, channelSelector: string): string {
|
|
const lines = outputText.split(/\r?\n/);
|
|
const rewritten: string[] = [];
|
|
let insideSay = false;
|
|
let sawChannel = false;
|
|
|
|
const insertChannelIfNeeded = (): void => {
|
|
if (insideSay && !sawChannel) {
|
|
rewritten.push(`channel: ${channelSelector}`);
|
|
sawChannel = true;
|
|
}
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const kind = parseRepoFaceActionKind(line);
|
|
if (kind) {
|
|
insertChannelIfNeeded();
|
|
insideSay = kind === "say";
|
|
sawChannel = false;
|
|
rewritten.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (insideSay && line.trim() === "END") {
|
|
insertChannelIfNeeded();
|
|
insideSay = false;
|
|
rewritten.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (insideSay && /^channel(?:Id)?:/i.test(line.trim())) {
|
|
rewritten.push(`channel: ${channelSelector}`);
|
|
sawChannel = true;
|
|
continue;
|
|
}
|
|
|
|
rewritten.push(line);
|
|
if (insideSay && /^identity:/i.test(line.trim()) && !sawChannel) {
|
|
rewritten.push(`channel: ${channelSelector}`);
|
|
sawChannel = true;
|
|
}
|
|
}
|
|
|
|
insertChannelIfNeeded();
|
|
return rewritten.join("\n");
|
|
}
|
|
|
|
function parseInterpretationBlock(interpretationText: string): Record<string, string> {
|
|
const lines = interpretationText.split(/\r?\n/);
|
|
const start = lines.findIndex((line) =>
|
|
["INTERPRETATION", "REVIEW", "ROUTE"].includes(line.trim().toUpperCase()),
|
|
);
|
|
if (start < 0) {
|
|
return {};
|
|
}
|
|
const body: string[] = [];
|
|
for (let index = start + 1; index < lines.length; index += 1) {
|
|
if (lines[index].trim().toUpperCase() === "END") {
|
|
break;
|
|
}
|
|
body.push(lines[index]);
|
|
}
|
|
return parseRepoFaceActionFields(body);
|
|
}
|
|
|
|
function dropRepoFaceActionBlocks(
|
|
response: ProviderResponse,
|
|
interpretation: RepoFaceParentInterpretation,
|
|
): ProviderResponse {
|
|
const text = fitDiscordMessage(response.outputText ?? response.summary);
|
|
const privateSummary = stripRepoIdentityPostIntents(text) ||
|
|
`Parent interpreter dropped repo Face action blocks: ${interpretation.reasons.join("; ")}`;
|
|
return {
|
|
...response,
|
|
outputText: privateSummary,
|
|
summary: privateSummary,
|
|
metadata: {
|
|
...(response.metadata ?? {}),
|
|
repoFaceParentInterpreterDecision: interpretation.decision,
|
|
repoFaceParentInterpreterReasons: interpretation.reasons.join(" | "),
|
|
},
|
|
};
|
|
}
|
|
|
|
function repoFaceHeartbeatCodexOptions(job: JobRecord, role: "face" | "interpreter"): Record<string, string> {
|
|
if (job.command !== "repo-face-rumination") {
|
|
return {};
|
|
}
|
|
|
|
if (role === "face") {
|
|
return {
|
|
model: config.repoFaceHeartbeats.turnCodexModel,
|
|
...(config.repoFaceHeartbeats.codexModelReasoningEffort
|
|
? { reasoningEffort: config.repoFaceHeartbeats.codexModelReasoningEffort }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...(config.repoFaceHeartbeats.codexModel ? { model: config.repoFaceHeartbeats.codexModel } : {}),
|
|
...(config.repoFaceHeartbeats.codexModels.length > 0
|
|
? { models: config.repoFaceHeartbeats.codexModels.join(",") }
|
|
: {}),
|
|
...(config.repoFaceHeartbeats.codexModelReasoningEffort
|
|
? { reasoningEffort: config.repoFaceHeartbeats.codexModelReasoningEffort }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function maybeNotifyOwnerOfJobFailure(job: JobRecord, errorMessage: string): Promise<void> {
|
|
if (job.requester.id !== config.ownerDiscordId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await postOwnerNotification(
|
|
systemMessages.render("job.owner.worker_failed_dm", {
|
|
jobId: job.id,
|
|
command: job.command,
|
|
providerName: job.provider,
|
|
errorMessage,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to DM owner about job ${job.id}: ${
|
|
error instanceof Error ? error.message : "Unexpected notification failure."
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function deliverOwnerNotifications(
|
|
job: JobRecord,
|
|
notifications: ProviderNotificationIntent[],
|
|
): Promise<void> {
|
|
if (notifications.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const notification of notifications) {
|
|
try {
|
|
await postOwnerNotification(notification.message);
|
|
await auditLog.record({
|
|
type: "provider.notification_sent",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
channel: notification.channel,
|
|
reason: notification.reason,
|
|
message: notification.message,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Unexpected notification delivery failure.";
|
|
await auditLog.record({
|
|
type: "provider.notification_failed",
|
|
actorId: job.requester.id,
|
|
jobId: job.id,
|
|
provider: job.provider,
|
|
details: {
|
|
channel: notification.channel,
|
|
reason: notification.reason,
|
|
message: notification.message,
|
|
error: message,
|
|
},
|
|
});
|
|
console.error(`Failed owner notification for job ${job.id}: ${message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function writeArtifacts(
|
|
jobId: string,
|
|
artifacts: ProviderArtifact[],
|
|
): Promise<Record<string, string>> {
|
|
if (artifacts.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const artifactDirectory = join(config.artifactsDir, jobId);
|
|
await mkdir(artifactDirectory, { recursive: true });
|
|
|
|
const paths: Record<string, string> = {};
|
|
|
|
for (const artifact of artifacts) {
|
|
const filePath = join(artifactDirectory, artifact.name);
|
|
await writeFile(filePath, artifact.content, "utf8");
|
|
paths[artifact.name] = filePath;
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
|
|
async function postJobResponse(job: JobRecord, content: string): Promise<void> {
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required for the worker to post completed responses.");
|
|
}
|
|
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${job.outputChannelId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${config.botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content,
|
|
message_reference: job.requestMessageId
|
|
? {
|
|
message_id: job.requestMessageId,
|
|
fail_if_not_exists: false,
|
|
}
|
|
: undefined,
|
|
allowed_mentions: {
|
|
parse: [],
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Discord post failed with ${response.status}: ${await response.text()}`);
|
|
}
|
|
}
|
|
|
|
function parseRepoIdentityIdFromPrompt(prompt: string): string | undefined {
|
|
const match = prompt.match(/repo identity\s+.+?\(([^)]+)\)\s+for repo/i);
|
|
return (
|
|
match?.[1]?.trim() ??
|
|
prompt.match(/repo Face turn for .+?\(([^)]+)\) over repo/i)?.[1]?.trim() ??
|
|
prompt.match(/repo Face heartbeat for .+?\(([^)]+)\) over repo/i)?.[1]?.trim() ??
|
|
prompt.match(/identity "([^"]+)"/i)?.[1]?.trim()
|
|
);
|
|
}
|
|
|
|
interface RepoIdentityStateNoteIntent {
|
|
identity?: string;
|
|
kind: "memory" | "need" | "bond" | "status" | "mood" | "agency";
|
|
target?: string;
|
|
summary: string;
|
|
claim?: string;
|
|
question?: string;
|
|
tension?: string;
|
|
action?: string;
|
|
stance?: string;
|
|
status?: string;
|
|
mood?: string;
|
|
intensity?: number;
|
|
valence?: number;
|
|
}
|
|
|
|
interface RepoIdentityProposalPrIntent {
|
|
identity?: string;
|
|
title: string;
|
|
path?: string;
|
|
content: string;
|
|
shareContent?: string;
|
|
channelId?: string;
|
|
replyToMessageId?: string;
|
|
}
|
|
|
|
interface RepoIdentityPrCommentIntent {
|
|
identity?: string;
|
|
pr: string;
|
|
content: string;
|
|
shareContent?: string;
|
|
channelId?: string;
|
|
replyToMessageId?: string;
|
|
}
|
|
|
|
interface RepoIdentityUpdateRequestIntent {
|
|
identity?: string;
|
|
title: string;
|
|
content: string;
|
|
priority?: number;
|
|
sourceMessageIds: string[];
|
|
channelId?: string;
|
|
replyToMessageId?: string;
|
|
}
|
|
|
|
interface RepoIdentityBifrostTopicIntent {
|
|
identity?: string;
|
|
topicId?: string;
|
|
title?: string;
|
|
content: string;
|
|
mirrorContent?: string;
|
|
stance?: string;
|
|
priority?: number;
|
|
approve?: boolean;
|
|
dispatch?: boolean;
|
|
sourceMessageIds: string[];
|
|
channelId?: string;
|
|
replyToMessageId?: string;
|
|
}
|
|
|
|
interface BifrostBridgeReceipt {
|
|
action?: string;
|
|
ok?: boolean;
|
|
branch?: string;
|
|
prUrl?: string;
|
|
messageId?: string;
|
|
transport?: "bot" | "webhook";
|
|
}
|
|
|
|
interface BifrostUpdateRequestReceipt {
|
|
id: string;
|
|
status: "queued" | "claimed" | "completed" | "cancelled";
|
|
title: string;
|
|
targetRepoName: string;
|
|
targetAgentIdentity?: string;
|
|
priority: number;
|
|
}
|
|
|
|
interface BifrostGovernanceTopicReceipt {
|
|
id: string;
|
|
status: string;
|
|
title: string;
|
|
jurisdictionRepoName: string;
|
|
jurisdictionAgentIdentity?: string;
|
|
dispatchRequestId?: string;
|
|
}
|
|
|
|
async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIntent): Promise<boolean> {
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required for Bifrost to post repo identity responses.");
|
|
}
|
|
|
|
const identity = await resolveRepoIdentityForJobIntent(job, intent.identity);
|
|
if (!identity) {
|
|
console.warn(`Rejected repo identity speech for job ${job.id}: could not resolve identity.`);
|
|
return false;
|
|
}
|
|
|
|
const requestedChannelId = intent.channelId ?? repoIdentityDefaultSpeechChannel(job);
|
|
const channelId = normalizeRepoIdentitySpeechChannel(identity, job, requestedChannelId);
|
|
if (!channelId) {
|
|
console.warn(`Rejected repo identity ${identity.id} speech for job ${job.id}: no Discord channel was available.`);
|
|
return false;
|
|
}
|
|
if (requestedChannelId && requestedChannelId !== channelId) {
|
|
console.warn(
|
|
`Coerced repo identity ${identity.id} speech for job ${job.id} from "${requestedChannelId}" to conversation channel ${channelId}.`,
|
|
);
|
|
}
|
|
const rawContent = intent.content.trim();
|
|
if (isNonPublicRepoIdentitySpeech(rawContent)) {
|
|
throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`);
|
|
}
|
|
const content = normalizePublicRepoIdentitySpeech(rawContent);
|
|
if (isNonPublicRepoIdentitySpeech(content)) {
|
|
throw new Error(`Repo identity ${identity.id} SAY content is not public speech.`);
|
|
}
|
|
if (isOwnerDmChannelAlias(channelId)) {
|
|
const dmChannelId = await openOwnerDmChannel();
|
|
const postedMessages = await postDiscordBotMessageChunks(
|
|
dmChannelId,
|
|
renderRepoIdentityOwnerDm(identity, content),
|
|
);
|
|
const lastPosted = postedMessages[postedMessages.length - 1];
|
|
if (!lastPosted) {
|
|
throw new Error(`Owner DM posting for repo identity ${identity.id} returned no message receipt.`);
|
|
}
|
|
await recordRepoIdentityDeliveryReceipt({
|
|
identity,
|
|
channelId: dmChannelId,
|
|
content,
|
|
replyToMessageId: undefined,
|
|
messageId: lastPosted?.id,
|
|
transport: "bot",
|
|
}).catch((error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Could not record repo identity owner DM receipt for job ${job.id}: ${message}`);
|
|
});
|
|
console.log(
|
|
`Posted repo identity ${identity.id} to owner DM from job ${job.id} as ${postedMessages.length} message(s).`,
|
|
);
|
|
return true;
|
|
}
|
|
if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) {
|
|
console.warn(
|
|
`Rejected repo identity ${identity.id} speech for job ${job.id}: identity is not registered for Discord channel ${channelId}.`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (content.length > 1900) {
|
|
throw new Error(
|
|
`Repo identity ${identity.id} SAY content is too long for one Discord message (${content.length} characters).`,
|
|
);
|
|
}
|
|
if (/\S(?:\.\.\.|…)$/.test(content)) {
|
|
throw new Error(`Repo identity ${identity.id} SAY content appears mechanically truncated.`);
|
|
}
|
|
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-discord-post.md`, content);
|
|
const posted = runBifrostBridge([
|
|
"discord-post",
|
|
"--channel-id",
|
|
channelId,
|
|
"--content-file",
|
|
contentFile,
|
|
"--persona-name",
|
|
identity.displayName,
|
|
...(identity.avatarUrl ? ["--persona-avatar-url", identity.avatarUrl] : []),
|
|
...(intent.replyToMessageId ? ["--reply-to-message-id", intent.replyToMessageId] : []),
|
|
], { retries: 1 });
|
|
if (!posted.messageId || !posted.transport) {
|
|
throw new Error(`Bifrost Discord bridge returned no message receipt for job ${job.id}.`);
|
|
}
|
|
await recordRepoIdentityDeliveryReceipt({
|
|
identity,
|
|
channelId,
|
|
content,
|
|
replyToMessageId: intent.replyToMessageId,
|
|
messageId: posted.messageId,
|
|
transport: posted.transport,
|
|
}).catch((error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Could not record repo identity delivery receipt for job ${job.id}: ${message}`);
|
|
});
|
|
console.log(
|
|
`Posted repo identity ${identity.id} to Discord channel ${channelId} from job ${job.id} via ${posted.transport} message ${posted.messageId}.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
async function resolveRepoIdentityForJobIntent(
|
|
job: JobRecord,
|
|
identitySelector?: string,
|
|
): Promise<NonNullable<ReturnType<typeof findRepoDiscordIdentity>> | undefined> {
|
|
const jobIdentityId = job.command === "repo-face-rumination"
|
|
? parseRepoIdentityIdFromRequestMessageId(job.requestMessageId) ?? parseRepoIdentityIdFromPrompt(job.prompt)
|
|
: undefined;
|
|
const identitySelectorIsCurrentFaceAlias = isCurrentRepoFaceIdentitySelector(identitySelector);
|
|
if (
|
|
jobIdentityId &&
|
|
identitySelector &&
|
|
!identitySelectorIsCurrentFaceAlias &&
|
|
normalizeChannelSelector(jobIdentityId) !== normalizeChannelSelector(identitySelector)
|
|
) {
|
|
console.warn(
|
|
`Ignoring mismatched repo identity selector "${identitySelector}" for repo-face job ${job.id}; job identity is "${jobIdentityId}".`,
|
|
);
|
|
}
|
|
const identityId =
|
|
jobIdentityId ??
|
|
(identitySelectorIsCurrentFaceAlias ? undefined : identitySelector) ??
|
|
parseRepoIdentityIdFromPrompt(job.prompt) ??
|
|
parseRepoIdentityIdFromRequestMessageId(job.requestMessageId);
|
|
if (!identityId) {
|
|
return undefined;
|
|
}
|
|
|
|
const registry = await loadRegisteredFaceRepoRegistry();
|
|
return findRepoDiscordIdentity(registry, identityId);
|
|
}
|
|
|
|
async function loadRegisteredFaceRepoRegistry() {
|
|
return faceRegistryAsRepoDiscordRegistry(
|
|
await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath),
|
|
);
|
|
}
|
|
|
|
function parseRepoIdentityIdFromRequestMessageId(requestMessageId: string | undefined): string | undefined {
|
|
const match = requestMessageId?.match(/^agent-turn:([^:]+):/i);
|
|
return match?.[1]?.trim();
|
|
}
|
|
|
|
function parseRepoIdentityStateNoteIntents(finalResponse: string): RepoIdentityStateNoteIntent[] {
|
|
return parseRepoFaceActionBlocks(finalResponse)
|
|
.filter((block) => block.kind === "state_note")
|
|
.flatMap((block): RepoIdentityStateNoteIntent[] => {
|
|
const kind = normalizeStateNoteKind(block.fields.kind);
|
|
const summary = block.fields.summary?.trim() ?? "";
|
|
if (!kind || !summary) {
|
|
return [];
|
|
}
|
|
return [{
|
|
identity: optionalDslString(block.fields.identity),
|
|
kind,
|
|
target: optionalDslString(block.fields.target),
|
|
summary,
|
|
claim: optionalDslString(block.fields.claim),
|
|
question: optionalDslString(block.fields.question),
|
|
tension: optionalDslString(block.fields.tension),
|
|
action: optionalDslString(block.fields.action),
|
|
stance: optionalDslString(block.fields.stance),
|
|
status: optionalDslString(block.fields.status),
|
|
mood: optionalDslString(block.fields.mood),
|
|
intensity: parseDslNumber(block.fields.intensity),
|
|
valence: parseDslNumber(block.fields.valence),
|
|
}];
|
|
});
|
|
}
|
|
|
|
function parseRepoFaceArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] {
|
|
return parseRepoFaceActionBlocks(finalResponse)
|
|
.filter((block) => block.kind === "article")
|
|
.flatMap((block): RepoIdentityArticleIntent[] => {
|
|
const title = requiredDslString(block.fields.title);
|
|
const description = requiredDslString(block.fields.description);
|
|
const body = requiredDslString(block.fields.body ?? block.fields.content);
|
|
if (!title || !description || !body) {
|
|
return [];
|
|
}
|
|
const site = normalizeArticleSite(optionalDslString(block.fields.site));
|
|
const date = optionalDslString(block.fields.date);
|
|
if (date && !isValidArticleDate(date)) {
|
|
return [];
|
|
}
|
|
return [{
|
|
identity: optionalDslString(block.fields.identity),
|
|
site,
|
|
title,
|
|
description,
|
|
author: optionalDslString(block.fields.author),
|
|
date,
|
|
tags: parseDslList(block.fields.tags),
|
|
path: optionalDslString(block.fields.path),
|
|
body,
|
|
shareContent: optionalDslString(block.fields.share_content) ?? optionalDslString(block.fields.shareContent),
|
|
channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId),
|
|
replyToMessageId:
|
|
optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId),
|
|
}];
|
|
});
|
|
}
|
|
|
|
async function applyRepoIdentityStateNoteIntent(
|
|
job: JobRecord,
|
|
intent: RepoIdentityStateNoteIntent,
|
|
): Promise<void> {
|
|
const identity = await resolveRepoIdentityForJobIntent(job, intent.identity);
|
|
if (!identity) {
|
|
throw new Error(`Could not resolve repo identity for state note on job ${job.id}.`);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const target = stateNoteTarget(intent, identity);
|
|
const anchorRefs = [{ ref: `job:${job.id}`, kind: "runtime" }];
|
|
const intensity = clamp01(intent.intensity ?? 0.55);
|
|
const baseTags = [
|
|
"source:face-interpreter",
|
|
`kind:${intent.kind}`,
|
|
`repo:${identity.repoName}`,
|
|
];
|
|
|
|
const canonicalPath = resolveRepoFaceStatePath(identity, config.storageRoot);
|
|
const operation = (() => {
|
|
switch (intent.kind) {
|
|
case "need":
|
|
return {
|
|
operation: "upsert_affect_need",
|
|
need: {
|
|
needId: stableStateId("need", identity.id, intent.target ?? intent.summary),
|
|
kind: "agency",
|
|
status: intensity >= 0.75 ? "neglected" : "active",
|
|
target,
|
|
summary: intent.summary,
|
|
claim: intent.claim ?? intent.question ?? intent.summary,
|
|
question: intent.question,
|
|
tension: intent.tension ?? "The Face felt this strongly enough to preserve it.",
|
|
actionImplication: intent.action ?? "Let this need bend future attention and speech pressure.",
|
|
intensity,
|
|
valence: clampSigned(intent.valence ?? 0),
|
|
anchorRefs,
|
|
evidenceRefs: [],
|
|
sourceMemoryIds: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: baseTags,
|
|
},
|
|
};
|
|
case "bond":
|
|
return {
|
|
operation: "upsert_social_bond",
|
|
bond: {
|
|
bondId: stableStateId("bond", identity.id, intent.target ?? intent.summary),
|
|
target,
|
|
stance: normalizeSocialBondStance(intent.stance),
|
|
status: "active",
|
|
summary: intent.summary,
|
|
claim: intent.claim ?? intent.summary,
|
|
tension: intent.tension ?? "The relationship read is still developing.",
|
|
actionImplication: intent.action ?? "Let this social read color future interaction.",
|
|
intensity,
|
|
anchorRefs,
|
|
evidenceRefs: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: baseTags,
|
|
},
|
|
};
|
|
case "status":
|
|
return {
|
|
operation: "upsert_status_read",
|
|
read: {
|
|
readId: stableStateId("status", identity.id, intent.target ?? intent.summary),
|
|
target,
|
|
status: normalizeStatusRead(intent.status),
|
|
summary: intent.summary,
|
|
claim: intent.claim ?? intent.summary,
|
|
tension: intent.tension ?? "The status read may be partial.",
|
|
actionImplication: intent.action ?? "Let this change how the Face reads the room.",
|
|
intensity,
|
|
anchorRefs,
|
|
evidenceRefs: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: baseTags,
|
|
},
|
|
};
|
|
case "mood":
|
|
return {
|
|
operation: "update_mood_dimensions",
|
|
dimensions: [{
|
|
name: sanitizeStateToken(intent.mood ?? intent.target ?? "mood"),
|
|
value: intensity,
|
|
source: intent.summary.slice(0, 240),
|
|
updatedAt: now,
|
|
}],
|
|
updatedAt: now,
|
|
};
|
|
case "agency":
|
|
return {
|
|
operation: "upsert_agency_pressure",
|
|
pressure: {
|
|
pressureId: stableStateId("agency", identity.id, intent.target ?? intent.summary),
|
|
kind: "self_advocacy_request",
|
|
status: intensity >= 0.7 ? "ready_to_act" : "active",
|
|
target,
|
|
summary: intent.summary,
|
|
claim: intent.claim ?? intent.question ?? intent.summary,
|
|
question: intent.question,
|
|
tension: intent.tension ?? "The Face wants this acted on but has not resolved the path.",
|
|
actionImplication: intent.action ?? "Use future turns to sharpen this into speech, Bifrost work, or a proposal.",
|
|
intensity,
|
|
anchorRefs,
|
|
evidenceRefs: [],
|
|
sourceMemoryIds: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: baseTags,
|
|
},
|
|
};
|
|
case "memory":
|
|
default:
|
|
return {
|
|
operation: "record_short_term_memory",
|
|
memory: {
|
|
memoryId: stableStateId("memory", identity.id, `${intent.target ?? identity.repoName}:${intent.summary}`),
|
|
kind: "room_observation",
|
|
target,
|
|
summary: intent.summary,
|
|
claim: intent.claim ?? intent.question ?? intent.summary,
|
|
question: intent.question,
|
|
tension: intent.tension ?? "The Face chose to remember this because it may matter later.",
|
|
actionImplication: intent.action ?? "Let this memory influence future speech and investigation.",
|
|
anchorRefs,
|
|
evidenceRefs: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: baseTags,
|
|
},
|
|
};
|
|
}
|
|
})();
|
|
|
|
await applyVoidSelfStateOperation(
|
|
{
|
|
canonicalPath,
|
|
identity: {
|
|
agentId: identity.id,
|
|
publicName: identity.displayName,
|
|
publicDescription: identity.description,
|
|
},
|
|
},
|
|
operation,
|
|
);
|
|
}
|
|
|
|
async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdentityArticleIntent): Promise<void> {
|
|
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
|
|
if (!identityId) {
|
|
throw new Error(`Could not resolve repo identity for article intent on job ${job.id}.`);
|
|
}
|
|
|
|
const registry = await loadRegisteredFaceRepoRegistry();
|
|
const identity = findRepoDiscordIdentity(registry, identityId);
|
|
if (!identity) {
|
|
throw new Error(`No registered repo identity matched "${identityId}" for article intent on job ${job.id}.`);
|
|
}
|
|
|
|
const repoRoot = resolveArticleRepoRoot(intent, identity, {
|
|
sourceRepoRoot: config.sourceRepoRoot,
|
|
storageRoot: config.storageRoot,
|
|
});
|
|
const relativePath = normalizeArticlePath(intent, identity);
|
|
const articlePath = resolve(repoRoot, relativePath);
|
|
if (!isPathInside(repoRoot, articlePath)) {
|
|
throw new Error(`Article path escapes repo root for ${identity.id}: ${relativePath}`);
|
|
}
|
|
|
|
const articleMarkdown = renderRepoIdentityArticleMarkdown(intent, identity);
|
|
validateRenderedArticleMarkdown(articleMarkdown, intent);
|
|
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-article.md`, ensureTrailingNewline(articleMarkdown));
|
|
const pr = runBifrostBridge([
|
|
"github-draft-pr",
|
|
"--repo-root",
|
|
repoRoot,
|
|
"--identity",
|
|
identity.id,
|
|
"--title",
|
|
`${identity.id}: article: ${intent.title}`,
|
|
"--path",
|
|
relativePath,
|
|
"--content-file",
|
|
contentFile,
|
|
"--body",
|
|
`Draft bylined article submitted by repo Face ${identity.id}.\n\nPath: ${relativePath}`,
|
|
"--commit-message",
|
|
`${identity.id}: draft article ${intent.title}`,
|
|
]);
|
|
|
|
const prLine = pr.prUrl ? `\n\nPR: ${pr.prUrl}` : `\n\nDraft branch: ${pr.branch}`;
|
|
const articleLine = `\nArticle path: ${relativePath}`;
|
|
const shareContent =
|
|
intent.shareContent && intent.shareContent.trim().length > 0
|
|
? intent.shareContent.trim()
|
|
: `${identity.displayName}: I drafted "${intent.title}" as a bylined article and submitted it for review.`;
|
|
await postRepoIdentityIntent(job, {
|
|
identity: identity.id,
|
|
channelId: intent.channelId ?? job.outputChannelId,
|
|
replyToMessageId: intent.replyToMessageId,
|
|
content: `${shareContent}${prLine}${articleLine}`,
|
|
});
|
|
}
|
|
|
|
async function writeRepoIdentityProposalPrIntent(
|
|
job: JobRecord,
|
|
intent: RepoIdentityProposalPrIntent,
|
|
): Promise<void> {
|
|
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
|
|
if (!identityId) {
|
|
throw new Error(`Could not resolve repo identity for proposal PR intent on job ${job.id}.`);
|
|
}
|
|
|
|
const registry = await loadRegisteredFaceRepoRegistry();
|
|
const identity = findRepoDiscordIdentity(registry, identityId);
|
|
if (!identity) {
|
|
throw new Error(`No registered repo identity matched "${identityId}" for proposal PR intent on job ${job.id}.`);
|
|
}
|
|
|
|
const repoRoot = resolveRepoRoot(identity);
|
|
const relativePath = normalizeProposalPath(intent, identity);
|
|
const proposalPath = resolve(repoRoot, relativePath);
|
|
if (!isPathInside(repoRoot, proposalPath)) {
|
|
throw new Error(`Proposal path escapes repo root for ${identity.id}: ${relativePath}`);
|
|
}
|
|
|
|
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-proposal.md`, ensureTrailingNewline(intent.content));
|
|
const pr = runBifrostBridge([
|
|
"github-draft-pr",
|
|
"--repo-root",
|
|
repoRoot,
|
|
"--identity",
|
|
identity.id,
|
|
"--title",
|
|
`${identity.id}: proposal: ${intent.title}`,
|
|
"--path",
|
|
relativePath,
|
|
"--content-file",
|
|
contentFile,
|
|
"--body",
|
|
`Draft change proposal submitted by repo Face ${identity.id}.\n\nPath: ${relativePath}`,
|
|
"--commit-message",
|
|
`${identity.id}: draft proposal ${intent.title}`,
|
|
]);
|
|
|
|
const prLine = pr.prUrl ? `\n\nPR: ${pr.prUrl}` : `\n\nDraft branch: ${pr.branch}`;
|
|
const proposalLine = `\nProposal path: ${relativePath}`;
|
|
const shareContent =
|
|
intent.shareContent && intent.shareContent.trim().length > 0
|
|
? intent.shareContent.trim()
|
|
: `${identity.displayName}: I put the change proposal where it belongs: a draft PR for review.`;
|
|
await postRepoIdentityIntent(job, {
|
|
identity: identity.id,
|
|
channelId: intent.channelId ?? job.outputChannelId,
|
|
replyToMessageId: intent.replyToMessageId,
|
|
content: `${shareContent}${prLine}${proposalLine}`,
|
|
});
|
|
}
|
|
|
|
async function commentRepoIdentityPullRequestIntent(
|
|
job: JobRecord,
|
|
intent: RepoIdentityPrCommentIntent,
|
|
): Promise<void> {
|
|
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
|
|
if (!identityId) {
|
|
throw new Error(`Could not resolve repo identity for PR comment intent on job ${job.id}.`);
|
|
}
|
|
|
|
const registry = await loadRegisteredFaceRepoRegistry();
|
|
const identity = findRepoDiscordIdentity(registry, identityId);
|
|
if (!identity) {
|
|
throw new Error(`No registered repo identity matched "${identityId}" for PR comment intent on job ${job.id}.`);
|
|
}
|
|
|
|
const repoRoot = resolveRepoRoot(identity);
|
|
const prTarget = intent.pr.trim();
|
|
if (!prTarget) {
|
|
throw new Error(`PR comment intent for ${identity.id} on job ${job.id} did not include a PR target.`);
|
|
}
|
|
|
|
const body = `${identity.displayName} (${identity.id}) says:\n\n${intent.content.trim()}`;
|
|
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-pr-comment.md`, body);
|
|
runBifrostBridge([
|
|
"github-pr-comment",
|
|
"--repo-root",
|
|
repoRoot,
|
|
"--identity",
|
|
identity.id,
|
|
"--pr",
|
|
prTarget,
|
|
"--content-file",
|
|
contentFile,
|
|
]);
|
|
|
|
const shareContent =
|
|
intent.shareContent && intent.shareContent.trim().length > 0
|
|
? intent.shareContent.trim()
|
|
: `${identity.displayName}: I left my argument on the PR so the proposal has a real review trail.`;
|
|
await postRepoIdentityIntent(job, {
|
|
identity: identity.id,
|
|
channelId: intent.channelId ?? job.outputChannelId,
|
|
replyToMessageId: intent.replyToMessageId,
|
|
content: `${shareContent}\n\nPR: ${prTarget}`,
|
|
});
|
|
}
|
|
|
|
function repoIdentityUpdateRequestToBifrostTopic(
|
|
intent: RepoIdentityUpdateRequestIntent,
|
|
): RepoIdentityBifrostTopicIntent {
|
|
return {
|
|
identity: intent.identity,
|
|
title: intent.title,
|
|
content: [
|
|
"Legacy UPDATE REQUEST was reconciled into a Bifrost topic instead of immediate dispatch.",
|
|
"",
|
|
intent.content.trim(),
|
|
].join("\n"),
|
|
mirrorContent: `I put the work request on Bifrost instead of throwing it straight at Codex: ${intent.title}`,
|
|
stance: "proposal",
|
|
priority: intent.priority,
|
|
approve: false,
|
|
dispatch: false,
|
|
sourceMessageIds: intent.sourceMessageIds,
|
|
channelId: intent.channelId,
|
|
replyToMessageId: intent.replyToMessageId,
|
|
};
|
|
}
|
|
|
|
async function submitRepoIdentityBifrostTopicIntent(
|
|
job: JobRecord,
|
|
intent: RepoIdentityBifrostTopicIntent,
|
|
): Promise<void> {
|
|
const identityId = intent.identity ?? parseRepoIdentityIdFromPrompt(job.prompt);
|
|
if (!identityId) {
|
|
throw new Error(`Could not resolve repo identity for Bifrost topic intent on job ${job.id}.`);
|
|
}
|
|
|
|
const registry = await loadRegisteredFaceRepoRegistry();
|
|
const identity = findRepoDiscordIdentity(registry, identityId);
|
|
if (!identity) {
|
|
throw new Error(`No registered repo identity matched "${identityId}" for Bifrost topic intent on job ${job.id}.`);
|
|
}
|
|
|
|
const contentFile = await writeBifrostPayloadFile(
|
|
job,
|
|
`${identity.id}-bifrost-topic.md`,
|
|
ensureTrailingNewline(intent.content),
|
|
);
|
|
const mirrorContentFile = intent.mirrorContent
|
|
? await writeBifrostPayloadFile(
|
|
job,
|
|
`${identity.id}-bifrost-topic-mirror.md`,
|
|
ensureTrailingNewline(intent.mirrorContent),
|
|
)
|
|
: undefined;
|
|
const sourceMessageIds = Array.from(new Set([
|
|
...intent.sourceMessageIds,
|
|
intent.replyToMessageId,
|
|
].filter((value): value is string => typeof value === "string" && value.trim().length > 0)));
|
|
const sourceChannelId = normalizeBifrostSourceChannelId(intent.channelId ?? job.outputChannelId);
|
|
|
|
let topicId = intent.topicId;
|
|
const createWithTopicId =
|
|
topicId && intent.title && !intent.approve && !bifrostGovernanceTopicExists(topicId)
|
|
? topicId
|
|
: undefined;
|
|
if (createWithTopicId) {
|
|
topicId = undefined;
|
|
}
|
|
if (!topicId) {
|
|
if (!intent.title) {
|
|
throw new Error(`Bifrost topic intent for ${identity.id} on job ${job.id} needs title when topicId is absent.`);
|
|
}
|
|
|
|
const opened = runBifrostGovernanceThreads([
|
|
"open",
|
|
"--repo",
|
|
identity.repoName,
|
|
"--agent",
|
|
identity.id,
|
|
...(createWithTopicId ? ["--id", createWithTopicId] : []),
|
|
"--title",
|
|
intent.title,
|
|
"--summary-file",
|
|
contentFile,
|
|
"--priority",
|
|
String(normalizeBifrostPriority(intent.priority)),
|
|
"--source-kind",
|
|
"repo_face_turn",
|
|
"--created-by",
|
|
identity.id,
|
|
...(sourceChannelId ? ["--source-channel-id", sourceChannelId] : []),
|
|
...(sourceMessageIds.length > 0 ? ["--source-message-ids", sourceMessageIds.join(",")] : []),
|
|
...bifrostMirrorArgs(identity, mirrorContentFile),
|
|
]) as BifrostGovernanceTopicReceipt;
|
|
topicId = opened.id;
|
|
} else if (!intent.approve) {
|
|
runBifrostGovernanceThreads([
|
|
"comment",
|
|
"--topic",
|
|
topicId,
|
|
"--author",
|
|
identity.id,
|
|
"--author-kind",
|
|
"face",
|
|
"--stance",
|
|
normalizeBifrostTopicStance(intent.stance),
|
|
"--body-file",
|
|
contentFile,
|
|
...(sourceChannelId ? ["--source-channel-id", sourceChannelId] : []),
|
|
...(intent.replyToMessageId ? ["--source-message-id", intent.replyToMessageId] : []),
|
|
...bifrostMirrorArgs(identity, mirrorContentFile),
|
|
]);
|
|
}
|
|
|
|
if (intent.approve) {
|
|
runBifrostGovernanceThreads([
|
|
"approve",
|
|
"--topic",
|
|
topicId,
|
|
"--approved-by",
|
|
identity.id,
|
|
"--body-file",
|
|
contentFile,
|
|
...(sourceChannelId ? ["--source-channel-id", sourceChannelId] : []),
|
|
...(intent.replyToMessageId ? ["--source-message-id", intent.replyToMessageId] : []),
|
|
...bifrostMirrorArgs(identity, mirrorContentFile),
|
|
]);
|
|
}
|
|
|
|
let promoted: { topic?: BifrostGovernanceTopicReceipt; request?: BifrostUpdateRequestReceipt } | undefined;
|
|
if (intent.dispatch) {
|
|
promoted = runBifrostGovernanceThreads([
|
|
"promote",
|
|
"--topic",
|
|
topicId,
|
|
]) as {
|
|
topic?: BifrostGovernanceTopicReceipt;
|
|
request?: BifrostUpdateRequestReceipt;
|
|
};
|
|
}
|
|
|
|
console.log(
|
|
promoted?.request
|
|
? `Promoted Bifrost topic ${topicId} to update request ${promoted.request.id} for ${identity.id}/${identity.repoName} from job ${job.id}.`
|
|
: `Updated Bifrost topic ${topicId} for ${identity.id}/${identity.repoName} from job ${job.id}.`,
|
|
);
|
|
}
|
|
|
|
async function writeBifrostPayloadFile(job: JobRecord, fileName: string, content: string): Promise<string> {
|
|
const directory = resolve(config.storageRoot, "artifacts", job.id, "bifrost-bridge");
|
|
await mkdir(directory, { recursive: true });
|
|
const path = join(directory, sanitizePathSegment(fileName) || "payload.md");
|
|
await writeFile(path, content, "utf8");
|
|
return path;
|
|
}
|
|
|
|
function runBifrostGovernanceThreads(args: string[]): BifrostGovernanceTopicReceipt | Record<string, unknown> {
|
|
const governanceScript = resolve(config.bifrostRoot, "tools", "governance-threads.mjs");
|
|
const result = spawnSync(process.execPath, [governanceScript, ...args], {
|
|
cwd: config.bifrostRoot,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`Bifrost governance thread command failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(result.stdout) as BifrostGovernanceTopicReceipt | Record<string, unknown>;
|
|
} catch {
|
|
throw new Error(`Bifrost governance thread command returned non-JSON output: ${result.stdout}`);
|
|
}
|
|
}
|
|
|
|
function bifrostGovernanceTopicExists(topicId: string): boolean {
|
|
const governanceScript = resolve(config.bifrostRoot, "tools", "governance-threads.mjs");
|
|
const result = spawnSync(process.execPath, [governanceScript, "show", "--topic", topicId], {
|
|
cwd: config.bifrostRoot,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
return result.status === 0;
|
|
}
|
|
|
|
function normalizeBifrostSourceChannelId(channelId: string | undefined): string | undefined {
|
|
if (!channelId || isOwnerDmChannelAlias(channelId)) {
|
|
return undefined;
|
|
}
|
|
return channelId;
|
|
}
|
|
|
|
function bifrostMirrorArgs(
|
|
identity: { displayName: string; avatarUrl?: string },
|
|
mirrorContentFile?: string,
|
|
): string[] {
|
|
if (!config.bifrostDiscordChannelId) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
"--mirror-channel-id",
|
|
config.bifrostDiscordChannelId,
|
|
"--mirror-persona-name",
|
|
identity.displayName,
|
|
...(identity.avatarUrl ? ["--mirror-persona-avatar-url", identity.avatarUrl] : []),
|
|
...(mirrorContentFile ? ["--mirror-content-file", mirrorContentFile] : []),
|
|
];
|
|
}
|
|
|
|
function runBifrostBridge(args: string[], options: { retries?: number } = {}): BifrostBridgeReceipt {
|
|
const bridgeScript = resolve(config.bifrostRoot, "tools", "bifrost-bridge.mjs");
|
|
const maxAttempts = 1 + Math.max(0, Math.floor(options.retries ?? 0));
|
|
let lastFailure = "";
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const result = spawnSync("node", [bridgeScript, ...args], {
|
|
cwd: config.bifrostRoot,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (result.status !== 0 || result.error) {
|
|
lastFailure = renderBridgeFailure(result, attempt, maxAttempts);
|
|
if (attempt < maxAttempts) {
|
|
console.warn(`Bifrost bridge attempt ${attempt}/${maxAttempts} failed; retrying. ${lastFailure}`);
|
|
continue;
|
|
}
|
|
throw new Error(`Bifrost bridge failed. ${lastFailure}`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(result.stdout) as BifrostBridgeReceipt;
|
|
} catch {
|
|
throw new Error(`Bifrost bridge returned non-JSON output: ${result.stdout}`);
|
|
}
|
|
}
|
|
|
|
throw new Error(`Bifrost bridge failed. ${lastFailure || "No bridge attempt completed."}`);
|
|
}
|
|
|
|
function renderBridgeFailure(
|
|
result: ReturnType<typeof spawnSync>,
|
|
attempt: number,
|
|
maxAttempts: number,
|
|
): string {
|
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
const errorMessage = result.error instanceof Error ? result.error.message : undefined;
|
|
return [
|
|
`attempt=${attempt}/${maxAttempts}`,
|
|
`status=${result.status ?? "null"}`,
|
|
`signal=${result.signal ?? "none"}`,
|
|
errorMessage ? `error=${errorMessage}` : undefined,
|
|
stderr ? `stderr=${stderr}` : undefined,
|
|
stdout ? `stdout=${stdout}` : undefined,
|
|
]
|
|
.filter((entry): entry is string => Boolean(entry))
|
|
.join(" ");
|
|
}
|
|
|
|
function parseRepoIdentityUpdateRequestIntents(finalResponse: string): RepoIdentityUpdateRequestIntent[] {
|
|
const intents: RepoIdentityUpdateRequestIntent[] = parseRepoFaceActionBlocks(finalResponse)
|
|
.filter((block) => block.kind === "update_request")
|
|
.flatMap((block): RepoIdentityUpdateRequestIntent[] => {
|
|
const title = requiredDslString(block.fields.title);
|
|
const content = requiredDslString(block.fields.content);
|
|
if (!title || !content) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
identity: optionalDslString(block.fields.identity),
|
|
title,
|
|
content,
|
|
priority: parseDslNumber(block.fields.priority),
|
|
sourceMessageIds: parseDslList(block.fields.source_message_ids ?? block.fields.sourceMessageIds),
|
|
channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId),
|
|
replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId),
|
|
},
|
|
];
|
|
});
|
|
|
|
for (const line of finalResponse.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith(REPO_IDENTITY_UPDATE_REQUEST_SENTINEL)) {
|
|
continue;
|
|
}
|
|
|
|
const payload = trimmed.slice(REPO_IDENTITY_UPDATE_REQUEST_SENTINEL.length).trim();
|
|
try {
|
|
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
|
const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
|
|
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
if (!title || !content) {
|
|
continue;
|
|
}
|
|
intents.push({
|
|
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
|
|
title,
|
|
content,
|
|
priority: typeof parsed.priority === "number" ? parsed.priority : undefined,
|
|
sourceMessageIds: Array.isArray(parsed.sourceMessageIds)
|
|
? parsed.sourceMessageIds.filter((entry): entry is string => typeof entry === "string")
|
|
: [],
|
|
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
|
|
replyToMessageId:
|
|
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return intents;
|
|
}
|
|
|
|
function parseRepoIdentityBifrostTopicIntents(finalResponse: string): RepoIdentityBifrostTopicIntent[] {
|
|
const intents: RepoIdentityBifrostTopicIntent[] = parseRepoFaceActionBlocks(finalResponse)
|
|
.filter((block) => block.kind === "bifrost_topic")
|
|
.flatMap((block): RepoIdentityBifrostTopicIntent[] => {
|
|
const topicId = optionalDslString(block.fields.topic_id) ?? optionalDslString(block.fields.topicId);
|
|
const title = optionalDslString(block.fields.title);
|
|
const content = requiredDslString(block.fields.content);
|
|
if (!content || (!topicId && !title)) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
identity: optionalDslString(block.fields.identity),
|
|
topicId,
|
|
title,
|
|
content,
|
|
mirrorContent: optionalDslString(block.fields.mirror) ?? optionalDslString(block.fields.mirrorContent),
|
|
stance: optionalDslString(block.fields.stance),
|
|
priority: parseDslNumber(block.fields.priority),
|
|
approve: parseDslBoolean(block.fields.approve),
|
|
dispatch: parseDslBoolean(block.fields.dispatch),
|
|
sourceMessageIds: parseDslList(block.fields.source_message_ids ?? block.fields.sourceMessageIds),
|
|
channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId),
|
|
replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId),
|
|
},
|
|
];
|
|
});
|
|
|
|
for (const line of finalResponse.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL)) {
|
|
continue;
|
|
}
|
|
|
|
const payload = trimmed.slice(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL.length).trim();
|
|
try {
|
|
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
|
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
const title = typeof parsed.title === "string" ? parsed.title.trim() : undefined;
|
|
const topicId = typeof parsed.topicId === "string" ? parsed.topicId.trim() : undefined;
|
|
if (!content || (!topicId && !title)) {
|
|
continue;
|
|
}
|
|
intents.push({
|
|
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
|
|
topicId,
|
|
title,
|
|
content,
|
|
mirrorContent: typeof parsed.mirrorContent === "string" ? parsed.mirrorContent.trim() : undefined,
|
|
stance: typeof parsed.stance === "string" ? parsed.stance.trim() : undefined,
|
|
priority: typeof parsed.priority === "number" ? parsed.priority : undefined,
|
|
approve: parsed.approve === true,
|
|
dispatch: parsed.dispatch === true,
|
|
sourceMessageIds: Array.isArray(parsed.sourceMessageIds)
|
|
? parsed.sourceMessageIds.filter((entry): entry is string => typeof entry === "string")
|
|
: [],
|
|
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
|
|
replyToMessageId:
|
|
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return intents;
|
|
}
|
|
|
|
function parseRepoIdentityProposalPrIntents(finalResponse: string): RepoIdentityProposalPrIntent[] {
|
|
const intents: RepoIdentityProposalPrIntent[] = [];
|
|
|
|
for (const line of finalResponse.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL)) {
|
|
continue;
|
|
}
|
|
|
|
const payload = trimmed.slice(REPO_IDENTITY_PROPOSAL_PR_SENTINEL.length).trim();
|
|
try {
|
|
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
|
const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
|
|
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
if (!title || !content) {
|
|
continue;
|
|
}
|
|
intents.push({
|
|
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
|
|
title,
|
|
path: typeof parsed.path === "string" ? parsed.path.trim() : undefined,
|
|
content,
|
|
shareContent: typeof parsed.shareContent === "string" ? parsed.shareContent.trim() : undefined,
|
|
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
|
|
replyToMessageId:
|
|
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return intents;
|
|
}
|
|
|
|
function parseRepoIdentityPrCommentIntents(finalResponse: string): RepoIdentityPrCommentIntent[] {
|
|
const intents: RepoIdentityPrCommentIntent[] = [];
|
|
|
|
for (const line of finalResponse.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)) {
|
|
continue;
|
|
}
|
|
|
|
const payload = trimmed.slice(REPO_IDENTITY_PR_COMMENT_SENTINEL.length).trim();
|
|
try {
|
|
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
|
const pr = typeof parsed.pr === "string" ? parsed.pr.trim() : "";
|
|
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
|
|
if (!pr || !content) {
|
|
continue;
|
|
}
|
|
intents.push({
|
|
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
|
|
pr,
|
|
content,
|
|
shareContent: typeof parsed.shareContent === "string" ? parsed.shareContent.trim() : undefined,
|
|
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
|
|
replyToMessageId:
|
|
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return intents;
|
|
}
|
|
|
|
function countRepoIdentityGithubActionIntents(finalResponse: string): number {
|
|
let count = parseRepoFaceActionBlocks(finalResponse).filter((block) => block.kind === "article").length;
|
|
|
|
for (const line of finalResponse.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (
|
|
trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) ||
|
|
trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)
|
|
) {
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
interface RepoFaceActionBlock {
|
|
kind: "say" | "state_note" | "article" | "bifrost_topic" | "update_request";
|
|
fields: Record<string, string>;
|
|
}
|
|
|
|
function parseRepoFaceActionBlocks(finalResponse: string): RepoFaceActionBlock[] {
|
|
const lines = finalResponse.split(/\r?\n/);
|
|
const blocks: RepoFaceActionBlock[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const kind = parseRepoFaceActionKind(lines[index]);
|
|
if (!kind) {
|
|
continue;
|
|
}
|
|
|
|
const bodyLines: string[] = [];
|
|
index += 1;
|
|
while (index < lines.length && lines[index].trim() !== "END") {
|
|
if (parseRepoFaceActionKind(lines[index])) {
|
|
index -= 1;
|
|
break;
|
|
}
|
|
bodyLines.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
blocks.push({
|
|
kind,
|
|
fields: parseRepoFaceActionFields(bodyLines),
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function isCurrentRepoFaceIdentitySelector(identitySelector: string | undefined): boolean {
|
|
const normalized = identitySelector ? normalizeChannelSelector(identitySelector) : "";
|
|
return normalized.length > 0 && CURRENT_REPO_FACE_IDENTITY_SELECTORS.has(normalized);
|
|
}
|
|
|
|
function parseRepoFaceActionKind(line: string): RepoFaceActionBlock["kind"] | undefined {
|
|
switch (line.trim().toUpperCase()) {
|
|
case "SAY":
|
|
return "say";
|
|
case "STATE NOTE":
|
|
return "state_note";
|
|
case "ARTICLE":
|
|
return "article";
|
|
case "BIFROST TOPIC":
|
|
return "bifrost_topic";
|
|
case "UPDATE REQUEST":
|
|
return "update_request";
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function parseRepoFaceActionFields(lines: string[]): Record<string, string> {
|
|
const fields: Record<string, string> = {};
|
|
let currentKey: string | undefined;
|
|
let currentValue: string[] = [];
|
|
|
|
const flush = (): void => {
|
|
if (!currentKey) {
|
|
return;
|
|
}
|
|
fields[currentKey] = currentValue.join("\n").trim();
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/);
|
|
if (match) {
|
|
flush();
|
|
currentKey = normalizeDslKey(match[1]);
|
|
const inlineValue = match[2].trim();
|
|
currentValue = inlineValue.length > 0 && inlineValue !== "|" && inlineValue !== ">" ? [match[2]] : [];
|
|
continue;
|
|
}
|
|
if (currentKey) {
|
|
currentValue.push(line.replace(/^\s{2}/, ""));
|
|
}
|
|
}
|
|
flush();
|
|
return fields;
|
|
}
|
|
|
|
function normalizeDslKey(key: string): string {
|
|
return key.trim();
|
|
}
|
|
|
|
function optionalDslString(value: string | undefined): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed || trimmed.length === 0) {
|
|
return undefined;
|
|
}
|
|
if (["0", "null", "none", "undefined"].includes(trimmed.toLowerCase())) {
|
|
return undefined;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function requiredDslString(value: string | undefined): string | undefined {
|
|
return optionalDslString(value);
|
|
}
|
|
|
|
function parseDslNumber(value: string | undefined): number | undefined {
|
|
const trimmed = optionalDslString(value);
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function parseDslBoolean(value: string | undefined): boolean | undefined {
|
|
const normalized = optionalDslString(value)?.toLowerCase();
|
|
if (["true", "yes", "1"].includes(normalized ?? "")) {
|
|
return true;
|
|
}
|
|
if (["false", "no", "0"].includes(normalized ?? "")) {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseDslList(value: string | undefined): string[] {
|
|
const trimmed = optionalDslString(value);
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
return trimmed
|
|
.split(/[,\n]/)
|
|
.map((entry) => entry.trim().replace(/^-\s*/, ""))
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
function normalizeStateNoteKind(value: string | undefined): RepoIdentityStateNoteIntent["kind"] | undefined {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return ["memory", "need", "bond", "status", "mood", "agency"].includes(normalized ?? "")
|
|
? normalized as RepoIdentityStateNoteIntent["kind"]
|
|
: undefined;
|
|
}
|
|
|
|
function stateNoteTarget(
|
|
intent: RepoIdentityStateNoteIntent,
|
|
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>,
|
|
): { kind: "repo" | "person" | "room" | "system" | "self"; id: string; label?: string } {
|
|
const raw = intent.target?.trim();
|
|
if (!raw || raw.toLowerCase() === "self") {
|
|
return {
|
|
kind: "person",
|
|
id: identity.id,
|
|
label: identity.displayName,
|
|
};
|
|
}
|
|
if (raw.toLowerCase() === identity.repoName.toLowerCase()) {
|
|
return {
|
|
kind: "repo",
|
|
id: identity.repoName,
|
|
label: identity.repoName,
|
|
};
|
|
}
|
|
if (["room", "aquarium", "discord"].includes(raw.toLowerCase())) {
|
|
return {
|
|
kind: "room",
|
|
id: raw.toLowerCase(),
|
|
label: raw,
|
|
};
|
|
}
|
|
if (/^[a-z0-9_-]+$/i.test(raw) && raw.length <= 48) {
|
|
return {
|
|
kind: "person",
|
|
id: raw.toLowerCase(),
|
|
label: raw,
|
|
};
|
|
}
|
|
return {
|
|
kind: "system",
|
|
id: sanitizeStateToken(raw),
|
|
label: raw,
|
|
};
|
|
}
|
|
|
|
function normalizeSocialBondStance(value: string | undefined) {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return [
|
|
"fondness",
|
|
"rivalry",
|
|
"trust",
|
|
"irritation",
|
|
"protectiveness",
|
|
"envy",
|
|
"respect",
|
|
"suspicion",
|
|
"attachment",
|
|
].includes(normalized ?? "")
|
|
? normalized!
|
|
: "respect";
|
|
}
|
|
|
|
function normalizeStatusRead(value: string | undefined) {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return [
|
|
"favored",
|
|
"neglected",
|
|
"pampered",
|
|
"bypassed",
|
|
"blocked",
|
|
"challenged",
|
|
"ignored",
|
|
"consulted",
|
|
"threatened",
|
|
"admired",
|
|
].includes(normalized ?? "")
|
|
? normalized!
|
|
: "consulted";
|
|
}
|
|
|
|
function clamp01(value: number): number {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function clampSigned(value: number): number {
|
|
return Math.max(-1, Math.min(1, value));
|
|
}
|
|
|
|
function stableStateId(prefix: string, identityId: string, value: string): string {
|
|
const hash = createHash("sha1")
|
|
.update(`${identityId}:${prefix}:${value}`)
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
return `${prefix}-${identityId}-${hash}`;
|
|
}
|
|
|
|
function sanitizeStateToken(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 80) || "topic";
|
|
}
|
|
|
|
function normalizeBifrostPriority(value: number | undefined): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return 80;
|
|
}
|
|
return Math.max(1, Math.min(100, Math.round(value)));
|
|
}
|
|
|
|
function normalizeBifrostTopicStance(value: string | undefined): string {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return [
|
|
"comment",
|
|
"proposal",
|
|
"support",
|
|
"objection",
|
|
"question",
|
|
"approval",
|
|
"summary",
|
|
"receipt",
|
|
].includes(normalized ?? "")
|
|
? normalized!
|
|
: "comment";
|
|
}
|
|
|
|
function resolveRepoRoot(identity: { repoName: string; repoPath?: string }): string {
|
|
if (identity.repoPath && identity.repoPath.trim().length > 0) {
|
|
return resolve(identity.repoPath);
|
|
}
|
|
if (config.sourceRepoRoot) {
|
|
return resolve(config.sourceRepoRoot, identity.repoName);
|
|
}
|
|
return resolve(config.storageRoot, "repo-article-drafts", identity.repoName);
|
|
}
|
|
|
|
function normalizeProposalPath(
|
|
intent: RepoIdentityProposalPrIntent,
|
|
identity: { displayName: string },
|
|
): string {
|
|
const path = intent.path?.trim();
|
|
if (path && !isAbsolute(path) && !path.split(/[\\/]+/).includes("..")) {
|
|
return path.replace(/\\/g, "/");
|
|
}
|
|
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
return `Proposals/${sanitizePathSegment(identity.displayName)}/${date}-${slugify(intent.title)}.md`;
|
|
}
|
|
|
|
function stripRepoIdentityPostIntents(finalResponse: string): string {
|
|
return stripRepoFaceActionBlocks(finalResponse)
|
|
.split(/\r?\n/)
|
|
.filter((line) => {
|
|
const trimmed = line.trim();
|
|
return (
|
|
!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL) &&
|
|
!trimmed.startsWith(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL) &&
|
|
!trimmed.startsWith(REPO_IDENTITY_UPDATE_REQUEST_SENTINEL) &&
|
|
!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) &&
|
|
!trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)
|
|
);
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
function stripRepoFaceActionBlocks(finalResponse: string): string {
|
|
const lines = finalResponse.split(/\r?\n/);
|
|
const kept: string[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (parseRepoFaceActionKind(lines[index])) {
|
|
index += 1;
|
|
while (index < lines.length && lines[index].trim() !== "END") {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
kept.push(lines[index]);
|
|
}
|
|
return kept.join("\n");
|
|
}
|
|
|
|
function isPathInside(rootPath: string, candidatePath: string): boolean {
|
|
const relativePath = relative(resolve(rootPath), resolve(candidatePath));
|
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
}
|
|
|
|
function ensureTrailingNewline(value: string): string {
|
|
return value.endsWith("\n") ? value : `${value}\n`;
|
|
}
|
|
|
|
function slugify(value: string): string {
|
|
return sanitizePathSegment(value)
|
|
.replace(/\.+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.slice(0, 80) || "article";
|
|
}
|
|
|
|
function sanitizePathSegment(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
async function recordRepoIdentityDeliveryReceipt(input: {
|
|
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>;
|
|
channelId: string;
|
|
content: string;
|
|
replyToMessageId?: string;
|
|
messageId: string;
|
|
transport: "bot" | "webhook";
|
|
}): Promise<void> {
|
|
if (input.identity.identityKind === "native_persona") {
|
|
return;
|
|
}
|
|
|
|
await applyVoidSelfStateOperation(
|
|
{
|
|
canonicalPath: resolveRepoFaceStatePath(input.identity, config.storageRoot),
|
|
identity: {
|
|
agentId: input.identity.id,
|
|
publicName: input.identity.displayName,
|
|
publicDescription: input.identity.description,
|
|
},
|
|
},
|
|
{
|
|
operation: "record_delivery_receipt",
|
|
receipt: {
|
|
receiptKey: `repo-identity:${input.identity.id}:${input.messageId}`,
|
|
sentAt: new Date().toISOString(),
|
|
mode: "repo_identity",
|
|
transport: input.transport,
|
|
channelId: input.channelId,
|
|
replyToMessageId: input.replyToMessageId,
|
|
personaName: input.identity.displayName,
|
|
personaAvatarUrl: input.identity.avatarUrl,
|
|
contentLength: input.content.length,
|
|
chunkCount: 1,
|
|
preview: input.content.slice(0, 1000),
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
await applyRepoFacePostFatigueAfterSpeech({
|
|
identity: input.identity,
|
|
storageRoot: config.storageRoot,
|
|
heartbeatStatePath: config.repoFaceHeartbeats.statePath,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Could not apply repo Face post fatigue for ${input.identity.id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
async function postOwnerNotification(content: string): Promise<void> {
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required for the worker to send owner notifications.");
|
|
}
|
|
|
|
const dmChannelId = await openOwnerDmChannel();
|
|
await postDiscordBotMessage(dmChannelId, fitDiscordMessage(content));
|
|
}
|
|
|
|
async function postDiscordBotMessage(channelId: string, content: string): Promise<{ id: string }> {
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required for the worker to post Discord messages.");
|
|
}
|
|
|
|
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${config.botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content: fitDiscordMessage(content),
|
|
allowed_mentions: {
|
|
parse: [],
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Discord owner notification failed with ${response.status}: ${await response.text()}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { id?: string };
|
|
if (!payload.id) {
|
|
throw new Error("Discord message post succeeded without returning a message id.");
|
|
}
|
|
|
|
return { id: payload.id };
|
|
}
|
|
|
|
async function postDiscordBotMessageChunks(channelId: string, content: string): Promise<Array<{ id: string }>> {
|
|
const posted: Array<{ id: string }> = [];
|
|
for (const chunk of splitDiscordMessage(content)) {
|
|
posted.push(await postDiscordBotMessage(channelId, chunk));
|
|
}
|
|
return posted;
|
|
}
|
|
|
|
async function openOwnerDmChannel(): Promise<string> {
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required for the worker to open an owner DM.");
|
|
}
|
|
|
|
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${config.botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
recipient_id: config.ownerDiscordId,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Discord DM open failed with ${response.status}: ${await response.text()}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { id?: string };
|
|
|
|
if (!payload.id) {
|
|
throw new Error("Discord DM open succeeded without returning a channel id.");
|
|
}
|
|
|
|
return payload.id;
|
|
}
|
|
|
|
function isOwnerDmChannelAlias(channelId: string): boolean {
|
|
return ["owner", "dm", "owner_dm", "private", "meta"].includes(channelId.trim().toLowerCase());
|
|
}
|
|
|
|
function isCurrentRoomChannelAlias(channelId: string): boolean {
|
|
return ["current", "current_room", "room", "here"].includes(channelId.trim().toLowerCase());
|
|
}
|
|
|
|
function repoIdentityDefaultSpeechChannel(job: JobRecord): string | undefined {
|
|
return job.guildContext?.channelId && !isOwnerDmChannelAlias(job.guildContext.channelId)
|
|
? job.guildContext.channelId
|
|
: job.outputChannelId;
|
|
}
|
|
|
|
function normalizeRepoIdentitySpeechChannel(
|
|
identity: { channelPermissions?: Array<{ channelId: string; label?: string }> },
|
|
job: JobRecord,
|
|
requestedChannelId: string | undefined,
|
|
): string | undefined {
|
|
if (requestedChannelId && isCurrentRoomChannelAlias(requestedChannelId)) {
|
|
return repoIdentityDefaultSpeechChannel(job);
|
|
}
|
|
|
|
const explicitChannelId = resolveRepoIdentityChannelSelector(identity, requestedChannelId);
|
|
if (explicitChannelId) {
|
|
return explicitChannelId;
|
|
}
|
|
|
|
if (!requestedChannelId || !isOwnerDmChannelAlias(requestedChannelId)) {
|
|
return requestedChannelId;
|
|
}
|
|
|
|
if (repoIdentityOwnerDmExplicitlyAllowed(job)) {
|
|
return requestedChannelId;
|
|
}
|
|
|
|
return repoIdentityDefaultSpeechChannel(job);
|
|
}
|
|
|
|
function resolveRepoIdentityChannelSelector(
|
|
identity: { channelPermissions?: Array<{ channelId: string; label?: string }> },
|
|
selector: string | undefined,
|
|
): string | undefined {
|
|
const normalized = normalizeChannelSelector(selector);
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
return identity.channelPermissions?.find((permission) =>
|
|
normalizeChannelSelector(permission.channelId) === normalized ||
|
|
normalizeChannelSelector(permission.label) === normalized
|
|
)?.channelId;
|
|
}
|
|
|
|
function normalizeChannelSelector(selector: string | undefined): string {
|
|
return (selector ?? "").trim().replace(/^#/, "").toLowerCase();
|
|
}
|
|
|
|
function repoIdentityOwnerDmExplicitlyAllowed(job: JobRecord): boolean {
|
|
if (job.command !== "repo-face-rumination") {
|
|
return true;
|
|
}
|
|
|
|
return /\bowner-private\b|\bowner dm allowed\b|\bdirect owner dm\b|\bprivate dm to metacrat\b/i.test(job.prompt);
|
|
}
|
|
|
|
function renderRepoIdentityOwnerDm(
|
|
identity: { displayName: string; id: string },
|
|
content: string,
|
|
): string {
|
|
return `**${identity.displayName} (${identity.id})**\n${content}`.trim();
|
|
}
|
|
|
|
function fitDiscordMessage(content: string): string {
|
|
const trimmed = content.trim();
|
|
|
|
if (trimmed.length <= 1900) {
|
|
return trimmed;
|
|
}
|
|
|
|
return `${trimmed.slice(0, 1897)}...`;
|
|
}
|
|
|
|
function splitDiscordMessage(content: string): string[] {
|
|
const trimmed = content.trim();
|
|
const limit = 1900;
|
|
if (trimmed.length <= limit) {
|
|
return [trimmed];
|
|
}
|
|
|
|
const chunks: string[] = [];
|
|
let remaining = trimmed;
|
|
while (remaining.length > limit) {
|
|
let cut = remaining.lastIndexOf("\n\n", limit);
|
|
if (cut < Math.floor(limit * 0.6)) {
|
|
cut = remaining.lastIndexOf("\n", limit);
|
|
}
|
|
if (cut < Math.floor(limit * 0.6)) {
|
|
cut = remaining.lastIndexOf(" ", limit);
|
|
}
|
|
if (cut < Math.floor(limit * 0.6)) {
|
|
cut = limit;
|
|
}
|
|
chunks.push(remaining.slice(0, cut).trim());
|
|
remaining = remaining.slice(cut).trim();
|
|
}
|
|
if (remaining) {
|
|
chunks.push(remaining);
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function formatHistoryResults(
|
|
results: Awaited<ReturnType<RetrievalService["searchHistory"]>>,
|
|
): Array<Record<string, unknown>> {
|
|
return results.map((result) => ({
|
|
score: Number(result.score.toFixed(4)),
|
|
text: result.text,
|
|
sourceId: result.sourceId,
|
|
channelId: result.metadata.channelId,
|
|
channelName: result.metadata.channelName,
|
|
authorId: result.metadata.authorId,
|
|
authorName: result.metadata.authorName,
|
|
timestamp: result.metadata.timestamp,
|
|
timeContext: formatTimeContext(result.metadata.timestamp),
|
|
jumpUrl: result.metadata.jumpUrl,
|
|
threadId: result.metadata.threadId,
|
|
}));
|
|
}
|
|
|
|
function formatSourceResults(
|
|
results: Awaited<ReturnType<RetrievalService["searchRepositorySources"]>>,
|
|
): Array<Record<string, unknown>> {
|
|
return results.map((result) => ({
|
|
score: Number(result.score.toFixed(4)),
|
|
text: result.text,
|
|
sourceId: result.sourceId,
|
|
repoName: result.metadata.repoName,
|
|
path: result.metadata.path,
|
|
language: result.metadata.language,
|
|
title: result.metadata.title,
|
|
chunkIndex: Number(result.metadata.chunkIndex ?? 0),
|
|
lineStart: Number(result.metadata.lineStart ?? 1),
|
|
lineEnd: Number(result.metadata.lineEnd ?? 1),
|
|
lastModifiedAt: result.metadata.lastModifiedAt,
|
|
}));
|
|
}
|
|
|
|
function formatArchivedMessageContext(
|
|
message: ArchivedMessageRecord,
|
|
anchorMessageId: string,
|
|
): Record<string, unknown> {
|
|
return {
|
|
id: message.id,
|
|
isAnchor: message.id === anchorMessageId,
|
|
timestamp: message.timestamp,
|
|
timeContext: formatTimeContext(message.timestamp),
|
|
authorId: message.authorId,
|
|
authorName: message.authorName,
|
|
channelId: message.channelId,
|
|
channelName: message.metadata?.channelName,
|
|
threadId: message.threadId,
|
|
content: message.content,
|
|
jumpUrl: message.metadata?.jumpUrl,
|
|
editedAt: message.editedAt,
|
|
};
|
|
}
|
|
|
|
function formatTimeContext(timestamp: string | undefined): string | undefined {
|
|
if (!timestamp) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new Date(timestamp);
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return undefined;
|
|
}
|
|
|
|
const absolute = formatUtcTimestamp(timestamp);
|
|
const now = new Date();
|
|
const deltaMs = now.getTime() - parsed.getTime();
|
|
const absDeltaMs = Math.abs(deltaMs);
|
|
const suffix = deltaMs >= 0 ? "ago" : "from now";
|
|
|
|
if (absDeltaMs < 60_000) {
|
|
return `${absolute} (just now)`;
|
|
}
|
|
|
|
const minuteMs = 60_000;
|
|
const hourMs = 60 * minuteMs;
|
|
const dayMs = 24 * hourMs;
|
|
const yearMs = 365 * dayMs;
|
|
|
|
if (absDeltaMs < hourMs) {
|
|
const minutes = Math.round(absDeltaMs / minuteMs);
|
|
return `${absolute} (${minutes} minute${minutes === 1 ? "" : "s"} ${suffix})`;
|
|
}
|
|
|
|
if (absDeltaMs < dayMs) {
|
|
const hours = Math.round(absDeltaMs / hourMs);
|
|
return `${absolute} (${hours} hour${hours === 1 ? "" : "s"} ${suffix})`;
|
|
}
|
|
|
|
if (absDeltaMs < yearMs) {
|
|
const days = Math.round(absDeltaMs / dayMs);
|
|
return `${absolute} (${days} day${days === 1 ? "" : "s"} ${suffix})`;
|
|
}
|
|
|
|
const years = Math.round(absDeltaMs / yearMs);
|
|
return `${absolute} (${years} year${years === 1 ? "" : "s"} ${suffix})`;
|
|
}
|
|
|
|
function formatUtcTimestamp(timestamp: string): string {
|
|
const date = new Date(timestamp);
|
|
const year = date.getUTCFullYear();
|
|
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
|
|
const day = `${date.getUTCDate()}`.padStart(2, "0");
|
|
const hours = `${date.getUTCHours()}`.padStart(2, "0");
|
|
const minutes = `${date.getUTCMinutes()}`.padStart(2, "0");
|
|
return `${year}-${month}-${day} ${hours}:${minutes} UTC`;
|
|
}
|