2026-07-30 11:50:48 +07:00
|
|
|
import { createChildLogger } from "@/shared/logger/index";
|
2026-06-04 18:52:56 +07:00
|
|
|
import { encoding_for_model as encodingForModel } from "tiktoken";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { formatMediaEvidenceForPrompt } from "../message-capture/messageMetadata.js";
|
|
|
|
|
import type { MessageRecord } from "../message-capture/types.js";
|
2026-07-31 17:55:02 +07:00
|
|
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-09 19:46:08 +07:00
|
|
|
const logger = createChildLogger("conversationContext");
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
export interface ConversationContextInput {
|
|
|
|
|
contextBefore: MessageRecord[];
|
|
|
|
|
targets: MessageRecord[];
|
|
|
|
|
maxTokens: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 22:50:23 +07:00
|
|
|
let _encoder: ReturnType<typeof encodingForModel> | null = null;
|
|
|
|
|
|
|
|
|
|
function getEncoder(): ReturnType<typeof encodingForModel> {
|
|
|
|
|
if (!_encoder) {
|
|
|
|
|
_encoder = encodingForModel("gpt-4o");
|
|
|
|
|
}
|
|
|
|
|
return _encoder;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
/**
|
|
|
|
|
* Formats a timestamp to ISO 8601 string
|
|
|
|
|
*/
|
|
|
|
|
function formatTimestamp(ms: number): string {
|
|
|
|
|
return new Date(ms).toISOString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-27 21:54:31 +07:00
|
|
|
* Estimates token count for a string using tiktoken for accurate counting
|
2026-06-01 21:44:29 +07:00
|
|
|
*/
|
|
|
|
|
export function estimateTokens(text: string): number {
|
2026-06-02 22:50:23 +07:00
|
|
|
// Use tiktoken for accurate token counting (+15 overhead for JSON structure)
|
2026-06-09 19:46:08 +07:00
|
|
|
const tokens = getEncoder().encode(text).length + 15;
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ tokenEstimate: tokens, textLength: text.length },
|
|
|
|
|
"Estimated tokens for text",
|
|
|
|
|
);
|
|
|
|
|
return tokens;
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-13 00:55:42 +07:00
|
|
|
/**
|
|
|
|
|
* Formats reference info for a message (reply/forward/crosspost)
|
|
|
|
|
*/
|
|
|
|
|
function formatReferenceInfo(msg: MessageRecord): string {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (msg.is_reply && msg.reference_message_id) {
|
|
|
|
|
parts.push(`[reply_to: ${msg.reference_message_id}]`);
|
|
|
|
|
if (msg.reference_channel_id) {
|
|
|
|
|
parts.push(`(reply_channel: ${msg.reference_channel_id})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (msg.is_forward && msg.reference_message_id) {
|
|
|
|
|
parts.push(`[forward_from: ${msg.reference_message_id}]`);
|
|
|
|
|
}
|
|
|
|
|
if (msg.is_crosspost) {
|
|
|
|
|
parts.push(`[crosspost]`);
|
|
|
|
|
}
|
|
|
|
|
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
/**
|
|
|
|
|
* Formats a single message for context or target display
|
|
|
|
|
*/
|
2026-06-04 17:33:08 +07:00
|
|
|
export function formatMessageForPrompt(
|
2026-06-01 21:44:29 +07:00
|
|
|
msg: MessageRecord,
|
|
|
|
|
label: "context" | "target",
|
2026-06-04 17:33:08 +07:00
|
|
|
): string {
|
2026-07-31 17:55:02 +07:00
|
|
|
const content = sanitizeDiscordTokens(
|
|
|
|
|
msg.edited_content ?? msg.content,
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
const timestamp = formatTimestamp(msg.created_at);
|
|
|
|
|
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
|
|
|
|
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
2026-06-13 00:55:42 +07:00
|
|
|
const refInfo = formatReferenceInfo(msg);
|
|
|
|
|
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}${refInfo}`;
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Builds conversation historical context without including targets.
|
|
|
|
|
* Calculates how much token budget targets use, and fills the rest with context.
|
|
|
|
|
*/
|
2026-06-04 17:33:08 +07:00
|
|
|
export function buildConversationContext(
|
2026-06-01 21:44:29 +07:00
|
|
|
input: ConversationContextInput,
|
2026-06-04 17:33:08 +07:00
|
|
|
): string[] {
|
2026-06-01 21:44:29 +07:00
|
|
|
const { contextBefore, targets, maxTokens } = input;
|
|
|
|
|
|
|
|
|
|
// Calculate tokens used by targets (parallel)
|
2026-06-04 18:52:56 +07:00
|
|
|
const targetLines = targets.map((msg) =>
|
|
|
|
|
formatMessageForPrompt(msg, "target"),
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
let usedTokens = targetLines.reduce(
|
|
|
|
|
(sum, line) => sum + estimateTokens(line),
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-04 18:52:56 +07:00
|
|
|
const contextLines = contextBefore.map((msg) =>
|
|
|
|
|
formatMessageForPrompt(msg, "context"),
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
const selectedContextLines: string[] = [];
|
|
|
|
|
|
|
|
|
|
// Go backwards through context, taking most recent first
|
|
|
|
|
for (let i = contextLines.length - 1; i >= 0; i--) {
|
|
|
|
|
const line = contextLines[i];
|
|
|
|
|
const lineTokens = estimateTokens(line);
|
|
|
|
|
|
|
|
|
|
if (usedTokens + lineTokens <= maxTokens) {
|
|
|
|
|
// Unshift so oldest context is first in the array
|
|
|
|
|
selectedContextLines.unshift(line);
|
|
|
|
|
usedTokens += lineTokens;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 19:46:08 +07:00
|
|
|
logger.debug(
|
|
|
|
|
{
|
|
|
|
|
targetCount: targets.length,
|
|
|
|
|
contextCount: selectedContextLines.length,
|
|
|
|
|
usedTokens,
|
|
|
|
|
maxTokens,
|
|
|
|
|
},
|
|
|
|
|
"Conversation context built",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
return selectedContextLines;
|
|
|
|
|
}
|