Add role-backed repo Discord identities
This commit is contained in:
parent
e8acb67141
commit
ed36561c8d
15 changed files with 427 additions and 9 deletions
|
|
@ -60,6 +60,9 @@ CODEX_MODEL_REASONING_EFFORT=low
|
|||
CODEX_EXEC_TIMEOUT_MS=120000
|
||||
STYLE_PACK_PATH=styles/void-default.md
|
||||
SYSTEM_MESSAGES_PATH=config/system-messages.json
|
||||
# Private registry for repo identities that use Discord roles as address targets
|
||||
# and the shared webhook persona pipe as their voice.
|
||||
REPO_DISCORD_IDENTITIES_PATH=.voidbot/private/repo-discord-identities.json
|
||||
INDEX_ALL_CHANNELS=false
|
||||
INDEXED_CHANNEL_IDS=
|
||||
EXCLUDED_CHANNEL_IDS=
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
OllamaSituationalSocialReadInferer,
|
||||
PermissionEngine,
|
||||
createStateStorage,
|
||||
findRepoDiscordIdentityByRoleIds,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
loadStylePack,
|
||||
loadSystemMessageCatalog,
|
||||
VoidUsageRateLimiter,
|
||||
|
|
@ -84,6 +86,17 @@ export async function startBot(): Promise<void> {
|
|||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
});
|
||||
const repoDiscordIdentities = await loadRepoDiscordIdentityRegistry(
|
||||
config.repoDiscordIdentitiesPath,
|
||||
);
|
||||
const botDirectedRoleIds = [
|
||||
...new Set([
|
||||
...config.botTriggerRoleIds,
|
||||
...repoDiscordIdentities.identities
|
||||
.map((identity) => identity.roleId)
|
||||
.filter((roleId): roleId is string => typeof roleId === "string" && roleId.length > 0),
|
||||
]),
|
||||
];
|
||||
|
||||
const permissionEngine = new PermissionEngine(config.ownerDiscordId, {
|
||||
localLlmAllowPublic: config.localLlm.allowPublic,
|
||||
|
|
@ -296,17 +309,34 @@ export async function startBot(): Promise<void> {
|
|||
config.channelIndexing,
|
||||
ragPipeline,
|
||||
client.user?.id,
|
||||
config.botTriggerRoleIds,
|
||||
botDirectedRoleIds,
|
||||
);
|
||||
await rememberAmbientVoidReference(message, client.user?.id, interactionMemory);
|
||||
|
||||
if (!client.user || (!isDirectMessage && !message.mentions.has(client.user))) {
|
||||
const addressedRepoIdentity = isDirectMessage
|
||||
? undefined
|
||||
: findRepoDiscordIdentityByRoleIds(
|
||||
repoDiscordIdentities,
|
||||
message.mentions.roles.keys(),
|
||||
message.channelId,
|
||||
);
|
||||
const isBotMentioned = Boolean(client.user && message.mentions.has(client.user));
|
||||
|
||||
if (!client.user || (!isDirectMessage && !isBotMentioned && !addressedRepoIdentity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = isDirectMessage ? message.content.trim() : stripBotMention(message.content).trim();
|
||||
const visiblePrompt = isDirectMessage
|
||||
? message.content.trim()
|
||||
: stripAddressingMentions(
|
||||
stripBotMention(message.content),
|
||||
addressedRepoIdentity?.roleId,
|
||||
).trim();
|
||||
const prompt = addressedRepoIdentity
|
||||
? buildRepoIdentityPrompt(visiblePrompt, addressedRepoIdentity)
|
||||
: visiblePrompt;
|
||||
|
||||
if (!prompt) {
|
||||
if (!visiblePrompt) {
|
||||
await replyToMessage(
|
||||
message,
|
||||
renderSystemMessage(activeSystemMessages, "mention.missing_prompt"),
|
||||
|
|
@ -317,7 +347,7 @@ export async function startBot(): Promise<void> {
|
|||
try {
|
||||
await handlePrompt({
|
||||
prompt,
|
||||
command: "ask",
|
||||
command: addressedRepoIdentity ? "repo-identity-mention" : "ask",
|
||||
actor: buildActorFromMessage(message),
|
||||
roleIds: getRoleIdsFromMessage(message),
|
||||
guildContext: buildGuildContextFromMessage(message),
|
||||
|
|
@ -342,6 +372,7 @@ export async function startBot(): Promise<void> {
|
|||
stylePack: activeStylePack,
|
||||
systemMessages: activeSystemMessages,
|
||||
silentOwnerQueueAck: !isDirectMessage,
|
||||
forceProvider: addressedRepoIdentity ? "owner_codex" : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
@ -372,7 +403,7 @@ export async function startBot(): Promise<void> {
|
|||
config.channelIndexing,
|
||||
ragPipeline,
|
||||
client.user?.id,
|
||||
config.botTriggerRoleIds,
|
||||
botDirectedRoleIds,
|
||||
);
|
||||
await rememberAmbientVoidReference(
|
||||
materializedMessage,
|
||||
|
|
@ -523,7 +554,7 @@ export async function startBot(): Promise<void> {
|
|||
permissionEngine,
|
||||
ragPipeline,
|
||||
client.user?.id,
|
||||
config.botTriggerRoleIds,
|
||||
botDirectedRoleIds,
|
||||
activeSystemMessages,
|
||||
);
|
||||
break;
|
||||
|
|
@ -577,3 +608,36 @@ export async function startBot(): Promise<void> {
|
|||
|
||||
await client.login(config.botToken);
|
||||
}
|
||||
|
||||
function stripAddressingMentions(content: string, roleId?: string): string {
|
||||
if (!roleId) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content.replace(new RegExp(`<@&${escapeRegExp(roleId)}>`, "g"), "").trim();
|
||||
}
|
||||
|
||||
function buildRepoIdentityPrompt(
|
||||
visiblePrompt: string,
|
||||
identity: {
|
||||
id: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
roleId?: string;
|
||||
},
|
||||
): string {
|
||||
const mention = identity.roleId ? `<@&${identity.roleId}>` : "unregistered-role";
|
||||
|
||||
return [
|
||||
`You were addressed on Discord as repo identity ${identity.displayName} (${identity.id}) for repo ${identity.repoName}.`,
|
||||
`The Discord role mention ${mention} is the addressable identity; the voice must speak through the VoidBot MCP tool post_repo_identity_message with identity "${identity.id}" if an in-channel answer is warranted.`,
|
||||
"Do not answer as base VoidBot. If you post with the tool, keep your final provider response to a private delivery summary; the worker will not auto-post this command's final text.",
|
||||
"",
|
||||
"User prompt:",
|
||||
visiblePrompt,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,24 @@ async function processJob(job: JobRecord): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (job.command === "repo-identity-mention") {
|
||||
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: false,
|
||||
reason: "repo_identity_must_post_through_mcp",
|
||||
},
|
||||
});
|
||||
console.log(`Completed repo identity mention job ${job.id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await postJobResponse(job, finalResponse);
|
||||
await jobQueue.completeJobDirect(job.id, finalResponse);
|
||||
await deliverOwnerNotifications(job, response.notifications ?? []);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ export const postDiscordMessageInputSchema = {
|
|||
personaAvatarUrl: z.string().url().max(512).optional(),
|
||||
};
|
||||
|
||||
export const postRepoIdentityMessageInputSchema = {
|
||||
identity: z.string().min(1),
|
||||
channelId: z.string().min(1),
|
||||
content: z.string().min(1).max(1800),
|
||||
replyToMessageId: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export const searchSourcesInputSchema = {
|
||||
query: z.string().min(1).max(240),
|
||||
limit: z.number().int().min(1).max(MAX_RETRIEVAL_RESULT_LIMIT).optional(),
|
||||
|
|
@ -73,6 +80,13 @@ export interface PostDiscordMessageArgs {
|
|||
personaAvatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PostRepoIdentityMessageArgs {
|
||||
identity: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
replyToMessageId?: string;
|
||||
}
|
||||
|
||||
export interface ListIndexedReposArgs {
|
||||
[key: string]: never;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|||
|
||||
import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared";
|
||||
import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
|
||||
import {
|
||||
findRepoDiscordIdentity,
|
||||
isRepoDiscordIdentityAllowedInChannel,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
} from "@voidbot/core";
|
||||
|
||||
import { type VoidbotMcpContext } from "./mcp-server-context";
|
||||
import { openOwnerDmChannel, postDiscordMessage } from "./mcp-server-discord";
|
||||
|
|
@ -11,6 +16,7 @@ import {
|
|||
type MessageContextArgs,
|
||||
type NotifyOwnerArgs,
|
||||
type PostDiscordMessageArgs,
|
||||
type PostRepoIdentityMessageArgs,
|
||||
type SearchHistoryArgs,
|
||||
type SearchSourcesArgs,
|
||||
type SourceContextArgs,
|
||||
|
|
@ -20,6 +26,7 @@ import {
|
|||
messageContextInputSchema,
|
||||
notifyOwnerInputSchema,
|
||||
postDiscordMessageInputSchema,
|
||||
postRepoIdentityMessageInputSchema,
|
||||
renderJsonBlock,
|
||||
searchHistoryInputSchema,
|
||||
searchSourcesInputSchema,
|
||||
|
|
@ -364,6 +371,162 @@ export function registerVoidbotTools(
|
|||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_repo_discord_identities",
|
||||
{
|
||||
title: "List Repo Discord Identities",
|
||||
description:
|
||||
"List registered repo identities that can be addressed by Discord roles and speak through the shared webhook persona pipe.",
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
},
|
||||
async (): Promise<CallToolResult> => {
|
||||
const registry = await loadRepoDiscordIdentityRegistry(
|
||||
context.config.repoDiscordIdentitiesPath,
|
||||
);
|
||||
const identities = registry.identities.map((identity) => ({
|
||||
id: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
roleId: identity.roleId ?? null,
|
||||
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
|
||||
allowedChannelIds: identity.allowedChannelIds,
|
||||
hasAvatarUrl: Boolean(identity.avatarUrl),
|
||||
description: identity.description ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
identities.length > 0
|
||||
? renderJsonBlock({
|
||||
identityCount: identities.length,
|
||||
identities,
|
||||
})
|
||||
: "No repo Discord identities are registered.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
identityCount: identities.length,
|
||||
identities,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"post_repo_identity_message",
|
||||
{
|
||||
title: "Post Repo Identity Message",
|
||||
description:
|
||||
"Post in Discord as a registered repo identity. The identity's Discord role is the mention target; this tool speaks through the shared webhook persona pipe using the registered display name and avatar.",
|
||||
inputSchema: postRepoIdentityMessageInputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
async (input: PostRepoIdentityMessageArgs): Promise<CallToolResult> => {
|
||||
const { identity: identitySelector, channelId, content, replyToMessageId } = input;
|
||||
const registry = await loadRepoDiscordIdentityRegistry(
|
||||
context.config.repoDiscordIdentitiesPath,
|
||||
);
|
||||
const identity = findRepoDiscordIdentity(registry, identitySelector);
|
||||
|
||||
if (!identity) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No registered repo Discord identity matched "${identitySelector}".`,
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
sent: false,
|
||||
reason: "unknown_identity",
|
||||
identity: identitySelector,
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Repo identity ${identity.id} is not registered for Discord channel ${channelId}.`,
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
sent: false,
|
||||
reason: "channel_not_allowed",
|
||||
identity: identity.id,
|
||||
channelId,
|
||||
allowedChannelIds: identity.allowedChannelIds,
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!context.config.botToken) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "DISCORD_BOT_TOKEN is not configured, so Discord posting is unavailable.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
sent: false,
|
||||
reason: "missing_bot_token",
|
||||
channelId,
|
||||
identity: identity.id,
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const posted = await postDiscordMessage(
|
||||
context.config.botToken,
|
||||
channelId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
{
|
||||
personaName: identity.displayName,
|
||||
personaAvatarUrl: identity.avatarUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: replyToMessageId
|
||||
? `Posted a reply in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`
|
||||
: `Posted a message in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`,
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
sent: true,
|
||||
channelId,
|
||||
messageId: posted.id,
|
||||
transport: posted.transport,
|
||||
identity: identity.id,
|
||||
repoName: identity.repoName,
|
||||
personaName: identity.displayName,
|
||||
roleId: identity.roleId ?? null,
|
||||
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
|
||||
replyToMessageId: replyToMessageId ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"notify_owner",
|
||||
{
|
||||
|
|
|
|||
15
config/repo-discord-identities.example.json
Normal file
15
config/repo-discord-identities.example.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"identities": [
|
||||
{
|
||||
"id": "aquarium-synth-host",
|
||||
"repoName": "AquariumSynthCSharp",
|
||||
"displayName": "AquariumSynthHost",
|
||||
"roleId": "DISCORD_ROLE_ID_FOR_ADDRESSING",
|
||||
"allowedChannelIds": [
|
||||
"AQUARIUM_CHANNEL_ID"
|
||||
],
|
||||
"avatarUrl": "https://example.com/aquarium-synth-host-avatar.png",
|
||||
"description": "Role mention is the address; webhook persona is the mouth."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- The intended shape now is: quiet runs can follow one seam for several passes, connect repo motion to lore or philosophy if the evidence supports it, and only surface with the juiciest version once the incubation queue says the thought has actually grown teeth.
|
||||
- Directly invoked Discord replies now load that same private moderation state as distilled private self-state context, so the ruminating scheduled loop and the summoned Void share one evolving personality/state spine. The prompt-facing self-state summary no longer dumps recent repo sweeps, novelty checks, or musing sludge back into working memory by default; it stays closer to seam memory, incubation, and room obligations. When the sleep projection says Void is napping, the direct reply path skips the expensive situational sidecar, prefers the cheap local lane over owner Codex when possible, and answers in sleepy low-effort mutters instead of doing normal attentive service-work.
|
||||
- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. That helper and the `post_discord_message` MCP tool now also support a shared guild-channel webhook pipe with per-message `personaName` and optional `personaAvatarUrl`, so swarm agents can speak as themselves without needing separate Discord bot identities.
|
||||
- Repo identities now have a cleaner address/speech split. A private registry at `REPO_DISCORD_IDENTITIES_PATH` maps identity id/repo/display name/avatar/role/channel allow-list; Discord users tag the role, while Codex agents speak through `post_repo_identity_message`. The worker suppresses base-bot auto-posting for `repo-identity-mention` jobs so the registered identity tool remains the mouth.
|
||||
- Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard.
|
||||
- The new state/notes surfaces are now the continuity spine for future nontrivial work.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
|
|||
- `apps/worker/src/mcp-server-resources.ts`
|
||||
- MCP resource registration for indexed repos plus history/source semantic-search resource templates.
|
||||
- `apps/worker/src/mcp-server-tools.ts`
|
||||
- MCP tool registration for archived history search, indexed source/lore search, context windows, repo listing, and owner notifications.
|
||||
- MCP tool registration for archived history search, indexed source/lore search, context windows, repo listing, registered repo-identity Discord speech, and owner notifications.
|
||||
- `apps/worker/src/mcp-server-shared.ts`
|
||||
- MCP input schemas, argument types, and shared result-formatting/resource helper functions.
|
||||
- `apps/worker/src/mcp-server-discord.ts`
|
||||
|
|
@ -148,6 +148,10 @@ This note is the source-grounded description of how the live VoidBot stack is sh
|
|||
- scheduled rumination skips during naps when there are no new room messages and no open cases, so sleep can distill the short-term surface without awake rumination immediately adding fresh residue.
|
||||
- `scripts/simulate-void-mood.mjs`
|
||||
- typed mood maintenance script that updates scheduled-runtime sleep cycle and speaking pressure in `.voidbot/private/void-self-state.cc`. It owns the sleep transition and invokes typed memory maintenance once per nap unless that nap already completed a maintenance pass. Speaking pressure now derives from queued candidates, incubation, and active typed agency pressure. The old personality-vector drift, memory organ, incubation, dream residue, cleanup, and legacy mirror behavior are offline pending typed replacements.
|
||||
- Repo Discord identities
|
||||
- `REPO_DISCORD_IDENTITIES_PATH` points at a private JSON registry of repo id, repo name, display name, optional avatar URL, optional role id, and allowed channel ids. The role is the Discord mention target; the webhook persona is the speech transport.
|
||||
- `list_repo_discord_identities` exposes the registered address book to Codex agents, and `post_repo_identity_message` enforces identity/channel registration before posting through the shared webhook pipe.
|
||||
- Role mentions create `repo-identity-mention` owner-Codex jobs with instructions to answer through `post_repo_identity_message`; the worker does not auto-post those final provider summaries as the base bot.
|
||||
- `scripts/run-void-mood-drift.ps1`
|
||||
- hidden-task wrapper for the mood-drift organ; writes status/log pulse files and respects the moderation lock so the two state writers do not stomp each other for sport.
|
||||
- `scripts/install-void-mood-drift-task.ps1`
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
|
|||
- the scheduled moderation runner has a typed rumination contract: bounded context in, reviewed prompt template, typed operation payloads out, parent-owned cursor/receipt recording
|
||||
- recent event timing in that bounded context now crosses a projection boundary first, so the child sees "5 minutes ago" language while the parent keeps exact timestamps for bookkeeping
|
||||
- public artifact sharing now crosses the same boundary: repo activity exposes content hints, rumination verifies authorship through retrieval, and a configured `publicSpeechTarget` lets Void queue a normal typed candidate intervention instead of needing a blog-specific announcement path
|
||||
- repo-scoped Discord participation now uses roles as the address boundary and registered webhook personas as the speech boundary. A role mention can summon a `repo-identity-mention` Codex job, but the worker suppresses base-bot auto-posting so the repo identity must speak through the `post_repo_identity_message` MCP tool.
|
||||
- repo-activity cursor advancement is parent-owned after successful normal rumination passes, so the child can inspect read-only repo weather without stale commits haunting every future pass as fresh material
|
||||
- the old memory-organ script family, legacy moderation state template, and legacy context exporter have now been deleted instead of preserved as forensic bait
|
||||
- deterministic cleanup is mostly recent scar tissue from failed manual cleanup passes; it should stay deleted rather than be ported unless a future typed operation protects a real invariant in the new model
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ const envSchema = z.object({
|
|||
.string()
|
||||
.min(1)
|
||||
.default(".voidbot/private/void-self-state.cc"),
|
||||
REPO_DISCORD_IDENTITIES_PATH: z
|
||||
.string()
|
||||
.min(1)
|
||||
.default(".voidbot/private/repo-discord-identities.json"),
|
||||
INDEX_ALL_CHANNELS: booleanFromEnv.default(false),
|
||||
INDEXED_CHANNEL_IDS: z.string().default(""),
|
||||
EXCLUDED_CHANNEL_IDS: z.string().default(""),
|
||||
|
|
@ -130,6 +134,7 @@ export interface AppConfig {
|
|||
stylePackPath: string;
|
||||
systemMessagesPath: string;
|
||||
moderationAgentStatePath: string;
|
||||
repoDiscordIdentitiesPath: string;
|
||||
indexAllChannels: boolean;
|
||||
indexedChannelIds: string[];
|
||||
excludedChannelIds: string[];
|
||||
|
|
@ -303,6 +308,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
stylePackPath: resolve(parsed.STYLE_PACK_PATH),
|
||||
systemMessagesPath: resolve(parsed.SYSTEM_MESSAGES_PATH),
|
||||
moderationAgentStatePath: resolve(parsed.MODERATION_AGENT_STATE_PATH),
|
||||
repoDiscordIdentitiesPath: resolve(parsed.REPO_DISCORD_IDENTITIES_PATH),
|
||||
indexAllChannels: parsed.INDEX_ALL_CHANNELS,
|
||||
indexedChannelIds,
|
||||
excludedChannelIds,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export * from "./context-builder";
|
|||
export * from "./interaction-memory";
|
||||
export * from "./job-queue";
|
||||
export * from "./permission-engine";
|
||||
export * from "./repo-discord-identities";
|
||||
export * from "./situational-social-read";
|
||||
export * from "./state-storage";
|
||||
export * from "./style-pack-loader";
|
||||
|
|
|
|||
124
packages/core/src/repo-discord-identities.ts
Normal file
124
packages/core/src/repo-discord-identities.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const repoDiscordIdentitySchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
repoName: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
roleId: z.string().trim().min(1).optional(),
|
||||
allowedChannelIds: z.array(z.string().trim().min(1)).default([]),
|
||||
avatarUrl: z.string().trim().url().max(512).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const repoDiscordIdentityRegistrySchema = z.object({
|
||||
identities: z.array(repoDiscordIdentitySchema).default([]),
|
||||
});
|
||||
|
||||
export type RepoDiscordIdentity = z.infer<typeof repoDiscordIdentitySchema>;
|
||||
|
||||
export interface RepoDiscordIdentityRegistry {
|
||||
identities: RepoDiscordIdentity[];
|
||||
}
|
||||
|
||||
export async function loadRepoDiscordIdentityRegistry(
|
||||
path: string,
|
||||
): Promise<RepoDiscordIdentityRegistry> {
|
||||
let raw: string;
|
||||
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return { identities: [] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stripLeadingBom(raw)) as unknown;
|
||||
const registry = Array.isArray(parsed)
|
||||
? repoDiscordIdentityRegistrySchema.parse({ identities: parsed })
|
||||
: repoDiscordIdentityRegistrySchema.parse(parsed);
|
||||
|
||||
return {
|
||||
identities: normalizeRepoDiscordIdentities(registry.identities),
|
||||
};
|
||||
}
|
||||
|
||||
export function findRepoDiscordIdentity(
|
||||
registry: RepoDiscordIdentityRegistry,
|
||||
selector: string,
|
||||
): RepoDiscordIdentity | undefined {
|
||||
const normalizedSelector = normalizeIdentityKey(selector);
|
||||
|
||||
return registry.identities.find((identity) => {
|
||||
return [
|
||||
identity.id,
|
||||
identity.repoName,
|
||||
identity.displayName,
|
||||
identity.roleId,
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.some((value) => normalizeIdentityKey(value) === normalizedSelector);
|
||||
});
|
||||
}
|
||||
|
||||
export function findRepoDiscordIdentityByRoleIds(
|
||||
registry: RepoDiscordIdentityRegistry,
|
||||
roleIds: Iterable<string>,
|
||||
channelId: string,
|
||||
): RepoDiscordIdentity | undefined {
|
||||
const mentionedRoles = new Set(roleIds);
|
||||
|
||||
return registry.identities.find((identity) => {
|
||||
return (
|
||||
identity.roleId &&
|
||||
mentionedRoles.has(identity.roleId) &&
|
||||
isRepoDiscordIdentityAllowedInChannel(identity, channelId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isRepoDiscordIdentityAllowedInChannel(
|
||||
identity: RepoDiscordIdentity,
|
||||
channelId: string,
|
||||
): boolean {
|
||||
return identity.allowedChannelIds.length === 0 || identity.allowedChannelIds.includes(channelId);
|
||||
}
|
||||
|
||||
function normalizeRepoDiscordIdentities(
|
||||
identities: RepoDiscordIdentity[],
|
||||
): RepoDiscordIdentity[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return identities.map((identity) => {
|
||||
const normalizedId = normalizeIdentityKey(identity.id);
|
||||
|
||||
if (seen.has(normalizedId)) {
|
||||
throw new Error(`Duplicate repo Discord identity id "${identity.id}".`);
|
||||
}
|
||||
|
||||
seen.add(normalizedId);
|
||||
|
||||
return {
|
||||
...identity,
|
||||
id: identity.id.trim(),
|
||||
repoName: identity.repoName.trim(),
|
||||
displayName: identity.displayName.trim().slice(0, 80),
|
||||
roleId: identity.roleId?.trim(),
|
||||
allowedChannelIds: [...new Set(identity.allowedChannelIds.map((entry) => entry.trim()))],
|
||||
avatarUrl: identity.avatarUrl?.trim(),
|
||||
description: identity.description?.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeIdentityKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export const commandNames = [
|
|||
"profile",
|
||||
"reindex-channel",
|
||||
"set-style",
|
||||
"repo-identity-mention",
|
||||
] as const;
|
||||
|
||||
export type CommandName = (typeof commandNames)[number];
|
||||
|
|
|
|||
|
|
@ -134,3 +134,4 @@
|
|||
{"ts":"2026-05-18T03:26:45Z","type":"verification","status":"ok","note":"Live rumination verified public artifact sharing: after projecting Void-authored artifacts to the top of repo weather and rewinding gamecult-site once, the model queued Epiphany and fractal-domain article share candidates with publicSpeechTarget delivery; the parent posted both and recorded typed speech receipts."}
|
||||
{"ts":"2026-05-18T05:03:00Z","type":"implementation","status":"ok","note":"Live-loop inspection found a duplicate direct reply: the logo question at Discord message 1505651526151176412 received receipts at 2026-05-17T23:41:40.434Z and 2026-05-17T23:56:12.783Z because the parent delivery step would still deliver a new queued candidate even when canonical typed speech receipts already proved that reply target was answered. The rumination runner now retires queued candidates whose channelId + replyToMessageId already appear in typed receipts, and the speech fixture seeds a prior receipt to prove that guard plus same-pass sibling retirement."}
|
||||
{"ts":"2026-05-18T08:45:30Z","type":"implementation","status":"ok","note":"Advocacy release valve opened: active self/world advocacy pressure now becomes a speech obligation at intensity 0.55 instead of 0.65, only a live queued candidate satisfies that obligation, older spoken candidates no longer count as ongoing release, and mood speaking-pressure weights now bias active advocacy earlier. speech-pressure, speech, and agency fixtures passed after the change."}
|
||||
{"ts":"2026-05-18T09:38:14Z","type":"implementation","status":"ok","note":"Repo Discord identities now use roles as address targets and the shared webhook pipe as speech transport: REPO_DISCORD_IDENTITIES_PATH stores registered identities, MCP exposes list_repo_discord_identities/post_repo_identity_message, role mentions create repo-identity-mention jobs, and the worker suppresses base-bot auto-post for those jobs."}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ accepted_design:
|
|||
- The repo-weather lane also should not keep dragging the same four-day commit carcass through every heartbeat. The recent repo-activity helper now advances the typed repo-activity cursor so already-injected commits stop coming back as fresh weather by default.
|
||||
- Repo weather should lead with particulars, not slogans. If two repos only rhyme at bumper-sticker altitude, keep the reports separate instead of forcing a fake unifying law over them.
|
||||
- Public artifacts attributed to Void should be discovered through the ordinary repo-weather content-hint and retrieval path, then presented through typed candidate interventions to the configured public room when Void confirms authorship and recent receipts/candidates do not show the artifact was already shared.
|
||||
- Repo-scoped agents should use Discord roles as addressable identities and the shared webhook persona pipe as the mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message` instead of arbitrary persona-name strings.
|
||||
- Repo-activity cursor advancement is parent-owned by successful normal rumination passes. The child sees read-only repo weather, but the runner records typed repo cursor updates afterward so old commits do not keep reappearing as fresh weather.
|
||||
- Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human.
|
||||
- When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules.
|
||||
|
|
@ -129,6 +130,7 @@ landed_baseline:
|
|||
- The local Ollama lane is now split into orchestration, prompt/render helpers, tool-loop helpers, and shared types/constants instead of one swollen provider file.
|
||||
- The Discord bot lane is now split into a gateway shell plus dedicated command-handler and support helper modules instead of one swollen entrypoint file.
|
||||
- The worker MCP surface is now split into a thin stdio entrypoint plus dedicated bootstrap, resource, tool, Discord-notify, and shared-helper modules instead of one swollen server file.
|
||||
- The MCP Discord surface now includes registered repo identities: `list_repo_discord_identities` exposes the role-backed address book, and `post_repo_identity_message` posts with the registered display name/avatar through the existing webhook pipe while enforcing channel allow-lists.
|
||||
- Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist.
|
||||
- Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth.
|
||||
- State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.
|
||||
|
|
|
|||
Loading…
Reference in a new issue