627 lines
21 KiB
TypeScript
627 lines
21 KiB
TypeScript
import {
|
|
type GuildContext,
|
|
loadPromptTemplate,
|
|
type SourceMessage,
|
|
type VoidSelfStateContext,
|
|
} from "@voidbot/shared";
|
|
import {
|
|
type VoidCandidateInterventions,
|
|
type VoidFaceAffect,
|
|
type VoidAgencyPressure,
|
|
type VoidModerationCursor,
|
|
type VoidScheduledRuntime,
|
|
type VoidSelfProfile,
|
|
type VoidSpeechReceipts,
|
|
type VoidThoughtMemory,
|
|
voidCandidateInterventionsSchema,
|
|
voidFaceAffectSchema,
|
|
voidAgencyPressureSchema,
|
|
voidModerationCursorSchema,
|
|
voidScheduledRuntimeSchema,
|
|
voidSelfProfileSchema,
|
|
voidSpeechReceiptsSchema,
|
|
voidThoughtMemorySchema,
|
|
} from "./void-self-state-domain";
|
|
|
|
export interface VoidSelfStateTypedProjection {
|
|
selfProfile: VoidSelfProfile;
|
|
moderationCursor: VoidModerationCursor;
|
|
speechReceipts: VoidSpeechReceipts;
|
|
thoughtMemory: VoidThoughtMemory;
|
|
scheduledRuntime: VoidScheduledRuntime;
|
|
agencyPressure: VoidAgencyPressure;
|
|
candidateInterventions: VoidCandidateInterventions;
|
|
faceAffect: VoidFaceAffect;
|
|
}
|
|
|
|
export interface VoidSelfStateProjectionOptions {
|
|
sourcePath: string;
|
|
loadedAt?: string;
|
|
recentMessages?: SourceMessage[];
|
|
guildContext?: GuildContext;
|
|
identity?: {
|
|
agentId?: string;
|
|
publicName: string;
|
|
publicDescription?: string;
|
|
};
|
|
}
|
|
|
|
export interface VoidSelfStateIdentityDefaults {
|
|
agentId: string;
|
|
publicName: string;
|
|
publicDescription?: string;
|
|
}
|
|
|
|
const MAX_PUBLIC_DESCRIPTION_LENGTH = 1000;
|
|
|
|
export function normalizeVoidSelfStateIdentityDefaults(
|
|
identity: VoidSelfStateIdentityDefaults,
|
|
): VoidSelfStateIdentityDefaults {
|
|
return {
|
|
...identity,
|
|
publicDescription: truncateOptionalText(
|
|
identity.publicDescription,
|
|
MAX_PUBLIC_DESCRIPTION_LENGTH,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function createEmptyVoidSelfState(
|
|
options: { createdAt?: string; identity?: VoidSelfStateIdentityDefaults } = {},
|
|
): VoidSelfStateTypedProjection {
|
|
const createdAt = options.createdAt ?? new Date().toISOString();
|
|
const identity = normalizeVoidSelfStateIdentityDefaults(
|
|
options.identity ?? {
|
|
agentId: "void-moderator",
|
|
publicName: "Void",
|
|
},
|
|
);
|
|
|
|
return {
|
|
selfProfile: voidSelfProfileSchema.parse({
|
|
schemaVersion: 1,
|
|
agentId: identity.agentId,
|
|
publicName: identity.publicName,
|
|
publicDescription: identity.publicDescription,
|
|
privateNotes: [],
|
|
values: [],
|
|
activationProfile: {
|
|
underlyingOrganization: {},
|
|
stableDispositions: {},
|
|
behavioralDimensions: {},
|
|
presentationStrategy: {},
|
|
voiceStyle: {},
|
|
situationalState: {},
|
|
},
|
|
updatedAt: createdAt,
|
|
}),
|
|
moderationCursor: voidModerationCursorSchema.parse({
|
|
schemaVersion: 1,
|
|
openCases: [],
|
|
repoActivityCursor: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
speechReceipts: voidSpeechReceiptsSchema.parse({
|
|
schemaVersion: 1,
|
|
recentReceipts: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
thoughtMemory: voidThoughtMemorySchema.parse({
|
|
schemaVersion: 1,
|
|
shortTerm: [],
|
|
memories: [],
|
|
incubation: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
scheduledRuntime: voidScheduledRuntimeSchema.parse({
|
|
schemaVersion: 1,
|
|
sleepCycle: {
|
|
isNapping: false,
|
|
activeDreamThemes: [],
|
|
},
|
|
speakingPressure: {
|
|
needToSpeak: 0,
|
|
},
|
|
lastRuns: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
agencyPressure: voidAgencyPressureSchema.parse({
|
|
schemaVersion: 1,
|
|
pressures: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
candidateInterventions: voidCandidateInterventionsSchema.parse({
|
|
schemaVersion: 1,
|
|
interventions: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
faceAffect: voidFaceAffectSchema.parse({
|
|
schemaVersion: 1,
|
|
needs: [],
|
|
socialBonds: [],
|
|
statusReads: [],
|
|
moodDimensions: [],
|
|
updatedAt: createdAt,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function truncateOptionalText(value: string | undefined, maxLength: number): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
if (trimmed.length <= maxLength) {
|
|
return trimmed;
|
|
}
|
|
return trimmed.slice(0, maxLength - 1).trimEnd();
|
|
}
|
|
|
|
export function buildVoidSelfStateContext(
|
|
typedState: VoidSelfStateTypedProjection,
|
|
options: VoidSelfStateProjectionOptions,
|
|
): VoidSelfStateContext {
|
|
return {
|
|
sourcePath: options.sourcePath,
|
|
loadedAt: options.loadedAt ?? new Date().toISOString(),
|
|
summary: renderVoidSelfStateSummary(typedState, options),
|
|
projection: buildVoidSelfStateProjection(typedState),
|
|
};
|
|
}
|
|
|
|
export function renderVoidSelfStateSummary(
|
|
state: VoidSelfStateTypedProjection,
|
|
options: Pick<VoidSelfStateProjectionOptions, "guildContext" | "recentMessages" | "identity"> = {},
|
|
): string {
|
|
const profile = state.selfProfile;
|
|
const identityName = options.identity?.publicName ?? profile.publicName;
|
|
const identityDescription = options.identity?.publicDescription ?? profile.publicDescription;
|
|
const topValues = state.selfProfile.values
|
|
.sort((left, right) => right.priority - left.priority)
|
|
.slice(0, 4)
|
|
.map((value) => value.label);
|
|
const activeMemories = state.thoughtMemory.memories
|
|
.filter((memory) => !memory.retiredAt);
|
|
const memories = activeMemories
|
|
.slice(-6)
|
|
.reverse()
|
|
.map((memory) => renderTypedMemory(memory, identityName));
|
|
const detailedMemoryIds = new Set(
|
|
activeMemories
|
|
.slice(-6)
|
|
.map((memory) => memory.memoryId),
|
|
);
|
|
const memoryAnchors = activeMemories
|
|
.filter((memory) => !detailedMemoryIds.has(memory.memoryId))
|
|
.map((memory) => renderTypedMemoryAnchor(memory));
|
|
const shortTermMemories = state.thoughtMemory.shortTerm
|
|
.slice(-6)
|
|
.reverse()
|
|
.map((memory) => renderTypedMemory(memory, identityName));
|
|
const incubation = state.thoughtMemory.incubation
|
|
.filter((thread) => thread.status !== "retired")
|
|
.sort((left, right) => right.maturation - left.maturation)
|
|
.slice(0, 4)
|
|
.map((thread) => renderTypedIncubationThread(thread));
|
|
const openCases = state.moderationCursor.openCases
|
|
.filter((entry) => !["answered", "resolved", "closed", "retired", "dropped"].includes(entry.status))
|
|
.slice(-4)
|
|
.reverse()
|
|
.map((entry) => `- ${entry.summary}${entry.authorName ? ` [from ${entry.authorName}]` : ""}`);
|
|
const interventions = state.candidateInterventions.interventions
|
|
.filter((entry) => entry.status === "queued" || entry.status === "deferred")
|
|
.slice(-3)
|
|
.reverse()
|
|
.map((entry) => `- ${entry.summary}: ${entry.draft}`);
|
|
const speechReceipts = state.speechReceipts.recentReceipts
|
|
.slice(-3)
|
|
.reverse()
|
|
.map((receipt) => {
|
|
const target = receipt.replyToMessageId
|
|
? `reply to ${receipt.replyToMessageId}`
|
|
: receipt.channelId
|
|
? `channel ${receipt.channelId}`
|
|
: "unspecified target";
|
|
const preview = receipt.preview ? `: ${receipt.preview}` : "";
|
|
return `- ${receipt.sentAt} via ${receipt.transport ?? "unknown transport"} at ${target}${preview}`;
|
|
});
|
|
const agencyPressures = state.agencyPressure.pressures
|
|
.filter((entry) => ["active", "cooling", "ready_to_act"].includes(entry.status))
|
|
.sort((left, right) => right.intensity - left.intensity)
|
|
.slice(0, 4)
|
|
.map((entry) => renderAgencyPressure(entry, identityName));
|
|
const affectLines = renderFaceAffectSummary(state.faceAffect, identityName);
|
|
|
|
return [
|
|
`- Identity: ${identityName}${identityDescription ? ` - ${identityDescription}` : ""}`,
|
|
profile.privateNotes.length > 0
|
|
? `- Private notes: ${profile.privateNotes.slice(0, 4).join(" | ")}`
|
|
: "- Private notes: none recorded.",
|
|
topValues.length > 0
|
|
? `- Highest-priority values: ${topValues.join(" | ")}`
|
|
: "- Highest-priority values: none recorded.",
|
|
renderTypedSleepCycleSummary(state.scheduledRuntime, state.thoughtMemory),
|
|
renderTypedSpeakingPressureSummary(state.scheduledRuntime),
|
|
openCases.length > 0
|
|
? [`- What ${identityName} owes the room:`, ...openCases].join("\n")
|
|
: `- What ${identityName} owes the room: nothing unresolved.`,
|
|
memories.length > 0
|
|
? [`- What ${identityName} remembers:`, ...memories].join("\n")
|
|
: `- What ${identityName} remembers: nothing durable yet.`,
|
|
memoryAnchors.length > 0
|
|
? [`- Other durable memory anchors still in reach:`, ...memoryAnchors].join("\n")
|
|
: `- Other durable memory anchors still in reach: none.`,
|
|
shortTermMemories.length > 0
|
|
? ["- Short-term residue awaiting sleep:", ...shortTermMemories].join("\n")
|
|
: "- Short-term residue awaiting sleep: none.",
|
|
incubation.length > 0
|
|
? ["- Thoughts still moving:", ...incubation].join("\n")
|
|
: "- Thoughts still moving: none.",
|
|
agencyPressures.length > 0
|
|
? ["- Agency pressure:", ...agencyPressures].join("\n")
|
|
: "- Agency pressure: none active.",
|
|
affectLines,
|
|
interventions.length > 0
|
|
? [`- Things ${identityName} may say soon:`, ...interventions].join("\n")
|
|
: `- Things ${identityName} may say soon: none queued.`,
|
|
speechReceipts.length > 0
|
|
? [`- What ${identityName} has said recently:`, ...speechReceipts].join("\n")
|
|
: `- What ${identityName} has said recently: nothing recorded.`,
|
|
renderTransientRoomLog(options),
|
|
].join("\n");
|
|
}
|
|
|
|
function renderAgencyPressure(
|
|
pressure: VoidAgencyPressure["pressures"][number],
|
|
identityName: string,
|
|
): string {
|
|
const target = pressure.target.label ?? pressure.target.id;
|
|
const lines = [`- ${target}: ${pressure.summary}`];
|
|
if (pressure.claim) {
|
|
lines.push(` What ${identityName} is asserting: ${pressure.claim}`);
|
|
}
|
|
if (pressure.question) {
|
|
lines.push(` What ${identityName} needs answered: ${pressure.question}`);
|
|
}
|
|
if (pressure.tension) {
|
|
lines.push(` What makes it difficult: ${pressure.tension}`);
|
|
}
|
|
lines.push(` What this pressure wants changed: ${pressure.actionImplication}`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderFaceAffectSummary(
|
|
affect: VoidFaceAffect,
|
|
identityName: string,
|
|
): string {
|
|
const needs = affect.needs
|
|
.filter((need) => ["active", "neglected"].includes(need.status))
|
|
.sort((left, right) => right.intensity - left.intensity)
|
|
.slice(0, 4);
|
|
const bonds = affect.socialBonds
|
|
.filter((bond) => bond.status === "active")
|
|
.sort((left, right) => right.intensity - left.intensity)
|
|
.slice(0, 4);
|
|
const reads = affect.statusReads
|
|
.filter((read) => !read.retiredAt)
|
|
.sort((left, right) => right.intensity - left.intensity)
|
|
.slice(0, 4);
|
|
const dimensions = affect.moodDimensions
|
|
.slice()
|
|
.sort((left, right) => right.value - left.value)
|
|
.slice(0, 8);
|
|
|
|
if (needs.length === 0 && bonds.length === 0 && reads.length === 0 && dimensions.length === 0) {
|
|
return `- What ${identityName} feels and wants: no explicit affect state yet.`;
|
|
}
|
|
|
|
return [
|
|
`- What ${identityName} feels and wants:`,
|
|
...needs.map((need) => {
|
|
const target = need.target.label ?? need.target.id;
|
|
return [
|
|
`- Need/${need.kind} for ${target} (${need.status}, ${need.intensity.toFixed(2)}): ${need.summary}`,
|
|
` ${identityName}'s claim: ${need.claim ?? need.question ?? "(unspoken)"}`,
|
|
` What rubs: ${need.tension}`,
|
|
` What it wants next: ${need.actionImplication}`,
|
|
].join("\n");
|
|
}),
|
|
...bonds.map((bond) => {
|
|
const target = bond.target.label ?? bond.target.id;
|
|
return [
|
|
`- Bond/${bond.stance} with ${target} (${bond.intensity.toFixed(2)}): ${bond.summary}`,
|
|
` ${identityName}'s read: ${bond.claim}`,
|
|
` How it should change behavior: ${bond.actionImplication}`,
|
|
].join("\n");
|
|
}),
|
|
...reads.map((read) => {
|
|
const target = read.target.label ?? read.target.id;
|
|
return [
|
|
`- Status read/${read.status} around ${target} (${read.intensity.toFixed(2)}): ${read.summary}`,
|
|
` ${identityName}'s read: ${read.claim}`,
|
|
` What it should do: ${read.actionImplication}`,
|
|
].join("\n");
|
|
}),
|
|
dimensions.length > 0
|
|
? `- Mood dimensions: ${dimensions.map((dimension) => `${dimension.name}=${dimension.value.toFixed(2)}`).join(", ")}`
|
|
: undefined,
|
|
]
|
|
.filter((line): line is string => typeof line === "string")
|
|
.join("\n");
|
|
}
|
|
|
|
export function buildVoidSelfStateProjection(
|
|
typedState: VoidSelfStateTypedProjection,
|
|
): VoidSelfStateContext["projection"] {
|
|
const sleepCycle = typedState.scheduledRuntime.sleepCycle;
|
|
const dreamSummaries = typedState.thoughtMemory.memories
|
|
.filter((memory) => memory.kind === "dream_residue" && !memory.retiredAt)
|
|
.slice(-3)
|
|
.reverse()
|
|
.map((memory) => memory.summary);
|
|
const isNapping = sleepCycle.isNapping === true;
|
|
const now = new Date();
|
|
const napStartedAt = formatRelativeTime(sleepCycle.currentNapStartedAt, now);
|
|
const napEndsAt = formatRelativeTime(sleepCycle.currentNapEndsAt, now);
|
|
const nextNapAt = formatRelativeTime(sleepCycle.nextNapStartsAt, now);
|
|
|
|
return {
|
|
mode: isNapping ? "napping" : "awake",
|
|
effortCeiling: isNapping ? "minimal" : "normal",
|
|
napStartedAt: isNapping ? napStartedAt : undefined,
|
|
napEndsAt: isNapping ? napEndsAt : undefined,
|
|
nextNapAt,
|
|
activeDreamThemes: sleepCycle.activeDreamThemes,
|
|
recentDreamSummaries: dreamSummaries,
|
|
replyDirective: isNapping
|
|
? loadPromptTemplate("runtime-nap-reply-directive.prompt.md")
|
|
: undefined,
|
|
affect: {
|
|
needs: typedState.faceAffect.needs
|
|
.filter((need) => ["active", "neglected"].includes(need.status))
|
|
.slice(0, 8)
|
|
.map((need) => ({
|
|
id: need.needId,
|
|
kind: need.kind,
|
|
status: need.status,
|
|
target: need.target,
|
|
summary: need.summary,
|
|
intensity: need.intensity,
|
|
valence: need.valence,
|
|
})),
|
|
socialBonds: typedState.faceAffect.socialBonds
|
|
.filter((bond) => bond.status === "active")
|
|
.slice(0, 8)
|
|
.map((bond) => ({
|
|
id: bond.bondId,
|
|
stance: bond.stance,
|
|
target: bond.target,
|
|
summary: bond.summary,
|
|
intensity: bond.intensity,
|
|
})),
|
|
statusReads: typedState.faceAffect.statusReads
|
|
.filter((read) => !read.retiredAt)
|
|
.slice(0, 8)
|
|
.map((read) => ({
|
|
id: read.readId,
|
|
status: read.status,
|
|
target: read.target,
|
|
summary: read.summary,
|
|
intensity: read.intensity,
|
|
})),
|
|
moodDimensions: typedState.faceAffect.moodDimensions
|
|
.slice(0, 16)
|
|
.map((dimension) => ({
|
|
name: dimension.name,
|
|
value: dimension.value,
|
|
source: dimension.source,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderTypedMemory(
|
|
memory: VoidThoughtMemory["memories"][number],
|
|
identityName: string,
|
|
): string {
|
|
const target = memory.target.label ?? memory.target.id;
|
|
const lines = [`- ${target}: ${memory.summary}`];
|
|
if (memory.claim) {
|
|
lines.push(` ${identityName}'s read: ${memory.claim}`);
|
|
}
|
|
if (memory.question) {
|
|
lines.push(` The question it keeps open: ${memory.question}`);
|
|
}
|
|
if (memory.tension) {
|
|
lines.push(` What keeps it honest: ${memory.tension}`);
|
|
}
|
|
if (memory.actionImplication) {
|
|
lines.push(` How it should change the next move: ${memory.actionImplication}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderTypedMemoryAnchor(
|
|
memory: VoidThoughtMemory["memories"][number],
|
|
): string {
|
|
const target = memory.target.label ?? memory.target.id;
|
|
const summary = truncateOptionalText(memory.summary, 180) ?? memory.summary;
|
|
const tags = memory.tags
|
|
.filter((tag) =>
|
|
tag.startsWith("repo:") ||
|
|
tag.startsWith("topic:") ||
|
|
tag.startsWith("value:") ||
|
|
tag.startsWith("identity:")
|
|
)
|
|
.slice(0, 4);
|
|
return `- ${target}: ${summary}${tags.length > 0 ? ` [${tags.join(", ")}]` : ""}`;
|
|
}
|
|
|
|
function renderTypedIncubationThread(thread: VoidThoughtMemory["incubation"][number]): string {
|
|
const target = thread.target.label ?? thread.target.id;
|
|
const lines = [`- ${thread.topic}: ${thread.summary}`];
|
|
lines.push(` It is pointed at ${target} and is currently ${describeIncubationStatus(thread.status)}.`);
|
|
if (thread.desireToSpeak !== undefined && thread.desireToSpeak >= 0.65) {
|
|
lines.push(" It has pressure to be said out loud.");
|
|
}
|
|
if (thread.saturationScore !== undefined && thread.saturationScore >= 0.75) {
|
|
lines.push(" It may be over-chewed; find new evidence or let it cool.");
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function describeIncubationStatus(status: VoidThoughtMemory["incubation"][number]["status"]): string {
|
|
switch (status) {
|
|
case "ready_to_share":
|
|
return "nearly ready to share";
|
|
case "cooling":
|
|
return "cooling";
|
|
case "crystallized":
|
|
return "settling into doctrine";
|
|
case "retired":
|
|
return "retired";
|
|
case "active":
|
|
default:
|
|
return "active";
|
|
}
|
|
}
|
|
|
|
function renderTypedSleepCycleSummary(
|
|
runtime: VoidScheduledRuntime,
|
|
thoughtMemory: VoidThoughtMemory,
|
|
): string {
|
|
const sleepCycle = runtime.sleepCycle;
|
|
const now = new Date();
|
|
const dreams = thoughtMemory.memories
|
|
.filter((memory) => memory.kind === "dream_residue" && !memory.retiredAt)
|
|
.slice(-2)
|
|
.reverse()
|
|
.map((memory) => `${memory.target.label ?? memory.target.id}: ${memory.summary}`);
|
|
const headline = sleepCycle.isNapping
|
|
? `- Sleep: napping${sleepCycle.currentNapEndsAt ? `; ends ${formatRelativeTime(sleepCycle.currentNapEndsAt, now)}` : ""}.`
|
|
: `- Sleep cycle: awake${sleepCycle.nextNapStartsAt ? `; next nap ${formatRelativeTime(sleepCycle.nextNapStartsAt, now)}` : ""}.`;
|
|
const themeLine =
|
|
sleepCycle.activeDreamThemes.length > 0
|
|
? `- Dream themes in reach: ${sleepCycle.activeDreamThemes.join(" | ")}`
|
|
: "- Dream themes in reach: none.";
|
|
const dreamLine =
|
|
dreams.length > 0
|
|
? `- Recent dream residue: ${dreams.join(" | ")}`
|
|
: "- Recent dream residue: none.";
|
|
|
|
return [headline, themeLine, dreamLine].join("\n");
|
|
}
|
|
|
|
function renderTypedSpeakingPressureSummary(runtime: VoidScheduledRuntime): string {
|
|
const pressure = runtime.speakingPressure;
|
|
const parts = [`overall ${describePressure(pressure.needToSpeak)}`];
|
|
if (pressure.confessionPressure !== undefined && pressure.confessionPressure >= 0.6) {
|
|
parts.push("self-disclosure is tugging");
|
|
}
|
|
if (pressure.noveltyPressure !== undefined && pressure.noveltyPressure >= 0.6) {
|
|
parts.push("something feels new enough to mention");
|
|
}
|
|
if (pressure.recentSpeechDamping !== undefined && pressure.recentSpeechDamping >= 0.5) {
|
|
parts.push("recent speech should dampen the impulse");
|
|
}
|
|
|
|
return `- Pressure to speak: ${parts.join(", ")}`;
|
|
}
|
|
|
|
function describePressure(value: number): "low" | "medium" | "high" {
|
|
if (value >= 0.7) {
|
|
return "high";
|
|
}
|
|
if (value >= 0.35) {
|
|
return "medium";
|
|
}
|
|
return "low";
|
|
}
|
|
|
|
function renderTransientRoomLog(
|
|
options: Pick<VoidSelfStateProjectionOptions, "guildContext" | "recentMessages">,
|
|
): string {
|
|
const recentMessages = (options.recentMessages ?? []).slice(-10);
|
|
const guildName = options.guildContext?.guildName ?? options.guildContext?.guildId ?? "(direct/unknown)";
|
|
const channelName =
|
|
options.guildContext?.channelName ?? options.guildContext?.channelId ?? "(unknown channel)";
|
|
|
|
if (recentMessages.length === 0) {
|
|
return [
|
|
"- Current room snapshot:",
|
|
`- Current room: guild=${guildName}; channel=${channelName}`,
|
|
"- Recent room: quiet in this snapshot.",
|
|
].join("\n");
|
|
}
|
|
|
|
const lines = recentMessages.map(
|
|
(message) =>
|
|
`- ${formatRelativeTime(message.timestamp)} ${message.authorName}: ${collapseWhitespace(message.content || "(no text content)")}`,
|
|
);
|
|
|
|
return [
|
|
"- Current room snapshot:",
|
|
`- Current room: guild=${guildName}; channel=${channelName}`,
|
|
...lines,
|
|
].join("\n");
|
|
}
|
|
|
|
function collapseWhitespace(value: string): string {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function formatRelativeTime(value: string | undefined, now = new Date()): string {
|
|
if (!value) {
|
|
return "recently";
|
|
}
|
|
|
|
const timestamp = Date.parse(value);
|
|
if (!Number.isFinite(timestamp)) {
|
|
return "recently";
|
|
}
|
|
|
|
const deltaMs = timestamp - now.getTime();
|
|
const future = deltaMs > 0;
|
|
const absoluteSeconds = Math.abs(deltaMs) / 1000;
|
|
const phrase = describeRelativeDuration(absoluteSeconds);
|
|
|
|
if (phrase === "just now") {
|
|
return phrase;
|
|
}
|
|
|
|
return future ? `in ${phrase}` : `${phrase} ago`;
|
|
}
|
|
|
|
function describeRelativeDuration(absoluteSeconds: number): string {
|
|
if (absoluteSeconds < 45) {
|
|
return "just now";
|
|
}
|
|
|
|
const minutes = Math.round(absoluteSeconds / 60);
|
|
if (minutes < 90) {
|
|
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 36) {
|
|
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
const days = Math.round(hours / 24);
|
|
if (days < 21) {
|
|
return `${days} day${days === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
const weeks = Math.round(days / 7);
|
|
if (weeks < 10) {
|
|
return `${weeks} week${weeks === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
const months = Math.round(days / 30);
|
|
if (months < 18) {
|
|
return `${months} month${months === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
const years = Math.round(days / 365);
|
|
return `${years} year${years === 1 ? "" : "s"}`;
|
|
}
|