feat: add conversation context builder

This commit is contained in:
MythEclipse
2026-05-14 19:10:13 +07:00
parent 2d511e08db
commit 2b4e2a7ab7
3 changed files with 181 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import type { MessageRecord } from "./types";
export interface ConversationContextInput {
contextBefore: MessageRecord[];
targets: MessageRecord[];
maxTokens: number;
}
/**
* Formats a timestamp to ISO 8601 string
*/
function formatTimestamp(ms: number): string {
return new Date(ms).toISOString();
}
/**
* Estimates token count for a string (rough approximation: ~4 chars per token)
*/
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Builds conversation prompt messages with context and targets
* - Marks target messages with [target], prior context with [context]
* - Uses edited_content when present, otherwise content
* - Maintains chronological order
* - Respects maxTokens budget, prioritizing targets and most recent context
*/
export function buildConversationPromptMessages(
input: ConversationContextInput,
): string[] {
const { contextBefore, targets, maxTokens } = input;
// Format all messages
const formatMessage = (msg: MessageRecord, label: string): string => {
const content = msg.edited_content ?? msg.content;
const timestamp = formatTimestamp(msg.created_at);
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`;
};
const targetLines = targets.map((msg) => formatMessage(msg, "target"));
const contextLines = contextBefore.map((msg) =>
formatMessage(msg, "context"),
);
// Calculate tokens for targets (always include)
let usedTokens = targetLines.reduce(
(sum, line) => sum + estimateTokens(line),
0,
);
// Add context lines in reverse chronological order (most recent first)
// until we hit the token budget
const selectedContextLines: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
const line = contextLines[i];
const lineTokens = estimateTokens(line);
if (usedTokens + lineTokens <= maxTokens) {
selectedContextLines.unshift(line); // prepend to maintain chronological order
usedTokens += lineTokens;
}
}
// Combine: context (chronological) + targets (chronological)
const allMessages = [...selectedContextLines, ...targetLines];
// Sort by timestamp to ensure chronological order
allMessages.sort((a, b) => {
const timeA = a.match(/time=([^\s]+)/)?.[1] ?? "";
const timeB = b.match(/time=([^\s]+)/)?.[1] ?? "";
return timeA.localeCompare(timeB);
});
return allMessages;
}

View File

@@ -458,3 +458,46 @@ export async function listReviewMessages(
status: ["warn", "flagged", "error"],
});
}
export async function getConversationContextBefore(input: {
channelId: string;
threadId: string | null;
beforeCreatedAt: number;
limit: number;
}): Promise<MessageRecord[]> {
try {
const db = getDatabase() as any;
const { channelId, threadId, beforeCreatedAt, limit } = input;
// Query same thread if threadId exists, otherwise channelId
const locationCondition = threadId
? eq(messagesTable.thread_id, threadId)
: eq(messagesTable.channel_id, channelId);
const rows = await db
.select()
.from(messagesTable)
.where(
and(
locationCondition,
sql`${messagesTable.created_at} < ${beforeCreatedAt}`,
isNull(messagesTable.deleted_at),
),
)
.orderBy(desc(messagesTable.created_at))
.limit(limit);
// Return in chronological order (oldest first)
return (rows as MessageRecord[]).reverse();
} catch (error) {
logger.error(
{
channelId: input.channelId,
threadId: input.threadId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get conversation context before",
);
throw error;
}
}