/** * Markdown → Feishu interactive card v2 body builder. * * Shared by `cli.ts` (`botmux send`) and `core/worker-pool.ts` (bridge * fallback final_output forwarding) so a model reply going through either * path renders identically in the Lark thread — same chrome, same markdown * rendering, same table widget. * * Implementation note: parsing is delegated to `markdown-it` (CommonMark + * GFM tables) instead of hand-rolled regex. The previous regex-based fence * splitter mis-fired on two real cases observed in production: * 1. Code fences directly adjacent to a prose line (no blank line) — Feishu's * markdown widget needs blank lines around fences, and the old splitter * didn't enforce them, so fences leaked through as literal `\`\`\`` text. * 2. Nested 3-backtick fences — the non-greedy regex closed the outer fence * at the first inner one, garbling everything after it. * markdown-it tokenizes correctly per CommonMark and gives us blank-line * normalization for free. For nested fences users should use 4+ backticks for * the outer block (CommonMark spec). */ import { type Locale } from '../../i18n/index.js'; import { REPLY_CARD_FOOTER_ELEMENT_ID } from './reply-card-footer-signature.js'; import type { FeedbackPolicy } from '../../services/feedback-policy.js'; export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js'; export type LocalHomeLinkMode = 'filesystem' | 'lexical' | 'disabled'; /** Native usage facts rendered in a Bot reply-card footer. Context is the * latest context-window measurement; tokens are cumulative for the Session. * Missing facts are omitted independently and must never be estimated. */ export interface CardUsageSnapshot { context: { usedTokens: number; windowTokens?: number; percentUsed?: number; } | null; tokens: { in: number; out: number; } | null; /** Delta for the latest user turn (small, matches the CLI TUI's per-turn * ↑↓). Null for dialects without per-turn tracking. Rendered on the live * streaming card; the reply-card footer stays compact and omits it. */ turnTokens?: { in: number; out: number; } | null; /** Latest executor-reported model. Rendered by session-status cards only. */ model?: string; /** Latest executor-reported reasoning effort. */ reasoningEffort?: string; } export interface ReplyCardFooter { /** Fully wrapped markdown content, reusable inside the voice-button row. */ content: string; /** Standalone footer element used by ordinary reply cards. */ element: { tag: 'markdown'; element_id: typeof REPLY_CARD_FOOTER_ELEMENT_ID; text_size: 'notation_small_v2'; content: string; }; } /** * Restore the leading slash when a model emits the current user's home path * as a relative Markdown link destination. Codex file links are normally * absolute (`/Users/alice/...` or `/home/alice/...`); without the slash, * Feishu resolves the destination as a relative URL and cannot open it. * * The repair is intentionally narrow: it only matches the current host home * prefix and only in destinations markdown-it recognizes as real inline links. * Web links, existing absolute paths, other users' homes, and general * relative links are left unchanged. An ambiguous home-shaped target is only * repaired when the absolute file exists and the same target does not exist * relative to the current working directory. */ export declare function normalizeLocalHomeLinks(input: string, homeDir?: string, cwd?: string, pathExists?: (path: string) => boolean, mode?: LocalHomeLinkMode): string; /** Default footer brand when a bot has no custom `brandLabel` configured. */ export declare const DEFAULT_BRAND_LABEL = "[botmux](https://github.com/deepcoldy/botmux)"; /** * Resolve the brand segment to render in a card footer from a bot's configured * `brandLabel` (see {@link resolveBrandLabel}): * • `undefined` (unset) → the default botmux link * • `''` / whitespace → `null` (brand suppressed) * • any other string → one trimmed line (markdown allowed) * Returning `null` lets callers drop the brand — and, when there's also no * recipient, the whole footer (HR included) — so an empty brand reads clean. */ export declare function brandFooterSegment(brand: string | undefined): string | null; /** Format usage as one segment shared by reply-card footers and the live * streaming card. The caller supplies native facts; this module only formats * them and never infers a context window or token count. Returns null when no * valid native metric is available. * * `variant`: * - `'footer'` (default): minimal — context only. Reply-card footers are * cramped (brand · usage · 发送给), so the large cumulative token string is * dropped here (it lives on the streaming card / usage ledger). 上下文占用 * is the one glanceable "how full am I" metric worth keeping. * - `'streaming'`: rich — context + `本轮 ↑X ↓Y`(per-turn delta, matches the * CLI TUI) + `累计 ↑A ↓B`(session total). The live card has room and * refreshes during execution. */ export declare function cardUsageFooterSegment(usage: CardUsageSnapshot, locale?: Locale, variant?: 'footer' | 'streaming'): string | null; /** Streaming-card runtime tail appended after * {@link cardUsageFooterSegment}'s metric text. Returns `**model** effort` * (model bolded within the shared grey markdown) or null when there is no model. * `effort` is dropped when absent — no placeholder. `hasMetrics` prevents a * standalone runtime-only row when native usage is unavailable. The * model↔effort join uses a non-breaking space so the pair never wraps apart. */ export declare function cardUsageRuntimeSegment(usage: CardUsageSnapshot, hasMetrics: boolean): string | null; /** Build the one canonical footer shared by all Bot Session reply cards. * Ordering, i18n, the parser marker, grey styling, and recipient rendering live * here so direct sends and daemon fallbacks cannot drift apart. */ export declare function buildReplyCardFooter(opts: { brand?: string; recipientOpenIds?: readonly string[]; usage?: CardUsageSnapshot; locale?: Locale; }): ReplyCardFooter | null; /** Clone a caller-supplied schema-2 card and append the canonical reply * footer. Returns null for cards without a v2 `body.elements` array or when a * caller-owned element already occupies the globally unique footer id. */ export declare function appendReplyCardFooterToV2Card(card: Record, opts: Parameters[0]): Record | null; /** Normalize source bytes that must be settled before the card is rendered. */ export declare function prepareCardMarkdown(input: string, cwd?: string, localHomeLinkMode?: LocalHomeLinkMode): string; /** * Split markdown into card v2 body elements: * 1. Pipe tables → native `table` widget (Feishu's markdown widget can't * render them as a grid). * 2. Headings → bold (Feishu's markdown widget doesn't render ATX `#`). * 3. Code fences → re-emitted with the original backtick run, joined with * blank lines on either side (Feishu's widget needs them to recognise the * fence). * 4. Everything else → original source slice, glued by blank lines. * * All non-table blocks are merged into a single `markdown` element to keep * card element counts modest. */ export declare function buildCardBodyElements(input: string, cwd?: string, localHomeLinkMode?: LocalHomeLinkMode): any[]; /** * Build card body elements from a `botmux send` body whose images were uploaded * via `--images` and referenced by `![alt](img:N)` placeholders (`N` is the * 0-based --images index): * * - `![](img:3)` — single index → full-width inline image. * - `![](img:0,1)` — 2+ comma-separated indices → one row of images side by * side. Row width = group size: `img:0,1` two per row, * `img:0,1,2` three per row. Each placeholder is one row. * - any image not named by a placeholder is appended full-width at the end. * * Placeholders are resolved to plain `![](img_key)` markdown (grouped ones onto * a single line) and handed to {@link buildCardBodyElements}, whose image-row * pre-pass turns multi-image lines into the actual `column_set` rows. This keeps * one rendering path: a caller that embeds `![](img_key)` directly and puts two * on a line (e.g. the menu poster) gets the same grid without using `--images`. */ export declare function buildImageCardElements(md: string, imageKeys: string[], cwd?: string, localHomeLinkMode?: LocalHomeLinkMode): any[]; /** * Heuristic: does `text` contain markdown syntax that renders badly as plain * text in Feishu (code fences, headings, lists, bold, inline code, links, * tables, blockquotes, hr)? Callers use this to decide between an interactive * card and a plain post. */ export declare function hasMarkdown(text: string): boolean; /** * Build a complete Feishu interactive card (schema 2.0) from a markdown * body, with the same footer chrome `botmux send` uses: HR + small grey * brand segment + optional `发送给:@` mention. * * `recipientOpenId` (when given) renders as `` in the * footer — typically the session owner. Pass `undefined` to omit the * addressing line (e.g. top-level broadcasts have no specific recipient). * * `brand` is the sending bot's configured `brandLabel` (see * {@link brandFooterSegment}): unset → default botmux link, `''` → brand * suppressed, else custom. When brand, usage, and recipient are all absent the * whole footer (HR included) is omitted. */ export declare function buildMarkdownCard(md: string, recipientOpenId?: string, brand?: string, locale?: Locale, workingDir?: string, localHomeLinkMode?: LocalHomeLinkMode, usage?: CardUsageSnapshot): string; /** Build the canonical final-answer card. Streaming/progress/session cards * must keep using their existing builders and never call this helper. */ export declare function buildCanonicalFinalReplyCard(opts: { markdown: string; feedback?: { policy: FeedbackPolicy; }; recipientOpenId?: string; brand?: string; locale?: Locale; workingDir?: string; localHomeLinkMode?: LocalHomeLinkMode; usage?: CardUsageSnapshot; }): string; /** * Build a contextual reply card: a title strip, an optional quoted user * prompt, and the assistant body rendered through the same markdown-it * pipeline as `buildMarkdownCard`. Used by: * • `/adopt` 前最后一轮 preamble — surfaces the last turn of the * adopted CLI session. * • Local-terminal turns synced back to Lark — when the user types * directly into the adopted pane, both sides of the exchange are * posted so the thread sees a complete conversation. * * Empty `userText` is rendered as a `(空)` placeholder inside the quote so * the visual layout stays consistent; pass `undefined` to omit the user * section entirely (headless variant). */ export declare function buildContextualReplyCard(opts: { title: string; userText?: string; assistantText: string; assistantLabel: string; recipientOpenId?: string; brand?: string; locale?: Locale; workingDir?: string; localHomeLinkMode?: LocalHomeLinkMode; usage?: CardUsageSnapshot; feedback?: { policy: FeedbackPolicy; }; }): string; //# sourceMappingURL=md-card.d.ts.map