/** * render.ts — Pure message-shaping helpers, used by both processes. * * Everything an incoming message passes through between the SDK and the * agent, and everything an outgoing turn is built from. These closed over * mutable state in the original (the message queue, the rate-limit * timestamp array, the dispatch counter), which is exactly why they were * untestable. Re-expressed here as pure functions that take their state * as arguments and return new state instead of mutating closures. * * Type-only imports: erased at compile time, so this file has zero * runtime dependency on ipc.ts (safe under node's native type stripping, * which does not remap .js -> .ts specifiers). */ import type { MediaInfo, QuotedInfo } from './ipc.js'; /** * The SDK's `IncomingMessage`, as far as normalization cares — declared * structurally rather than imported, so this module keeps its * zero-runtime-import property and a test can build one by hand. * * Every field is optional on purpose. The worker's handler is the last * place a message can vanish without a trace: iLink already considers it * delivered, and nothing downstream retries. So normalization assumes * nothing about which fields the SDK actually populated. */ export interface IncomingSdkMessage { userId?: unknown; text?: unknown; raw?: { message_id?: unknown; client_id?: unknown; } | null; timestamp?: { toISOString?: () => string; } | null; images?: readonly unknown[] | null; files?: readonly unknown[] | null; voices?: readonly ({ text?: string; durationMs?: number; } | null)[] | null; videos?: readonly ({ durationMs?: number; } | null)[] | null; quotedMessage?: { title?: string; text?: string; type?: string; } | null; } /** The `message` event, minus the discriminant the caller adds. */ export interface NormalizedIncoming { userId: string; text: string; msgId: string; timestamp: string; hasMedia: boolean; media: MediaInfo | null; voiceText: string; quoted: QuotedInfo | null; /** Attachments the SDK reported, of which only the first is downloaded. */ attachmentCount: number; } /** How many attachments the SDK reported, across all four kinds. */ export declare function attachmentCount(msg: IncomingSdkMessage): number; /** Duration of the attachment we downloaded, for the kinds that have one. */ export declare function mediaDurationMs(msg: IncomingSdkMessage, type: string): number | undefined; /** * Turn one SDK message into the event the extension consumes. * * `nowIso` is passed in rather than read from the clock so the result is * a function of its arguments alone. */ export declare function normalizeIncoming(msg: IncomingSdkMessage, media: MediaInfo | null, nowIso: string): NormalizedIncoming; /** Shape of an incoming WeChat message as far as rendering cares. */ export interface IncomingRenderable { hasMedia: boolean; media: MediaInfo | null; voiceText: string; quoted: QuotedInfo | null; /** * How many attachments the message carried. The SDK hands back one per * message, so a nine-image message loses eight of them — silently, until * this reached the agent, which can now say so instead of answering as * if it had seen everything. */ attachmentCount?: number; } /** * Render one incoming message: quoted context, media descriptors, then * the text itself. Returns '' for a message that carries nothing. */ export declare function renderIncoming(msg: IncomingRenderable, text: string): string; /** What survived the prefix filter, and what it renders to. */ export interface AcceptedBatch { /** One rendered string per accepted message, in order. */ parts: string[]; /** The messages those strings came from — the caller still needs their * media to inline, and their identity to put back on the queue. */ msgs: T[]; /** How many were turned away by the prefix, which has its own reply. */ prefixRejected: number; } /** * Apply the command-prefix filter to a batch and render what survives. * * Two distinct reasons a message can fail to reach the agent, and the * caller answers them differently: rejected by `aiPrefix` (the owner is * told how to address the bot) versus rendering to nothing at all — an * empty message, or media whose descriptors came out blank — where there * is nothing to say and dispatching a bare marker would just spend a turn. * * Generic in the message type so the caller gets its own `QueuedMessage`s * back rather than the structural subset this module needs. */ export declare function acceptForAgent(batch: readonly T[], aiPrefix: string): AcceptedBatch; /** * Build the marker stamped onto the agent message. * * `nonce` must be unpredictable, not just unique. A purely sequential tag * could be forged by a WeChat user typing the next one into their own * message body, which would hand them another user's answer. */ export declare function buildTurnMarker(userId: string, seq: number, nonce: string): string; /** The agent message: the marker, then the rendered parts of the batch. */ export declare function buildAgentMessage(marker: string, parts: readonly string[]): string; /** * Does this `agent_end` belong to the turn we dispatched? * * `turnUserText` is the user message that opened the finished turn, or * null when the walk back through the event found none. Null is treated * as ours: it is not a match, but neither is it evidence of *someone * else's* turn, and refusing would strand an answer that nothing else * will ever deliver. A user text that is present but lacks our marker is * the opposite — positive evidence, and the reason this check exists. * * More than one marker can belong to a single turn: a message delivered * as a steer joins the turn already running and becomes the *last* user * message in it, so the answer comes back tagged with the steer's marker * rather than the one that opened the turn. Matching any of the turn's * markers is what keeps that answer deliverable. */ export declare function matchesTurn(turnUserText: string | null, markers: readonly string[]): boolean; /** * Appended to the system prompt of a turn this channel dispatched. * * Prevention, not enforcement: it costs one paragraph and saves the * round trip a blocked tool call would spend. `blockTerminalOnlyTool` * is what actually guarantees the turn cannot be stranded. */ export declare function terminalOnlyToolsSteer(tools: readonly string[]): string; /** * The `reason` a blocked terminal-only tool call reports to the model. * * pi turns it into the tool result verbatim (`createErrorToolResult`), so * this is the only thing the model learns about what happened — it has to * carry both the fact and what to do instead. */ export declare function blockTerminalOnlyTool(toolName: string): string; export interface AgentTurnMessage { role?: string; /** A plain string, or pi's content blocks. Anything but text is skipped. */ content?: unknown; } /** The text of one message, whichever shape pi used for it. */ export declare function messageText(m: AgentTurnMessage | null | undefined): string; export interface TurnText { /** Everything the agent said in this turn, in order. */ responseText: string; /** * The user message that opened the turn, or null when the walk found * none — which `matchesTurn` deliberately treats as ours. */ turnUserText: string | null; } /** * Split an `agent_end` event into "what was said" and "who asked". * * Walked backwards and stopped at the first user message on purpose: the * event carries the whole conversation, and only the last turn is this * answer. The user text it stops on is what `matchesTurn` checks for the * turn marker, which is the whole reason a turn typed in the pi CLI is not * delivered to a WeChat user. */ export declare function extractTurnText(messages: readonly AgentTurnMessage[]): TurnText; export interface AgentTextContent { type: 'text'; text: string; } export interface AgentImageContent { type: 'image'; /** base64, no data: prefix. */ data: string; mimeType: string; } export type AgentContent = AgentTextContent | AgentImageContent; /** * The MIME type to hand the model for this file, or null when it is not * an image format worth inlining. * * Guessed from the extension because that is all the worker records: the * downloader names images `.jpg` regardless of what the CDN sent, so the * extension is a statement of what the file is meant to be. */ export declare function imageMimeType(filePath: string): string | null; /** * Assemble what goes to `pi.sendUserMessage()`. * * Returns the plain string when there is nothing but text, so the common * path stays byte-identical to what it was before images could be * inlined. With images, the text goes first: it carries the turn marker, * and `agent_end` matching depends on finding that marker in the user * message. */ export declare function buildAgentContent(text: string, images: readonly AgentImageContent[]): string | AgentContent[]; /** * Take the leading run of messages from the same sender, so a burst of * short messages ("看下这个" / "就是刚才那个文件" / "加个日志") becomes * one agent turn instead of three unrelated ones. * * Pure: does not mutate `queue`. Returns the batch plus what remains. */ export declare function takeBatch(queue: readonly T[], maxMerge: number): { batch: T[]; rest: T[]; }; /** * 0 = clear to dispatch now, otherwise how long until a slot frees up. * `kept` is `timestamps` filtered down to the ones still inside the * window — the caller should persist it back in place of `timestamps`. */ export declare function rateLimitWaitMs(timestamps: readonly number[], now: number, max: number, windowMs: number): { waitMs: number; kept: number[]; }; //# sourceMappingURL=render.d.ts.map