evOo-local/packages/adapters/src/user-progress.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

83 lines
3.1 KiB
TypeScript

import type { MessageBlock } from "@rakazo/contracts";
import { isToolActivityBlock } from "@rakazo/core";
/** Keep mid-turn progress beats short; prefer a few high-signal updates. */
export const USER_PROGRESS_MESSAGE_MAX_LENGTH = 500;
export function clampUserProgressMessage(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (trimmed.length <= USER_PROGRESS_MESSAGE_MAX_LENGTH) return trimmed;
return `${trimmed.slice(0, USER_PROGRESS_MESSAGE_MAX_LENGTH - 1).trimEnd()}…`;
}
/**
* True when the raw text a caller tried to post as a mid-turn progress update
* would not fit and got cut off by clampUserProgressMessage. Callers should
* surface this back to the model (it gets no other signal that its message
* was mangled) rather than reporting a silent success.
*/
export function isProgressMessageTruncated(text: string): boolean {
return text.trim().length > USER_PROGRESS_MESSAGE_MAX_LENGTH;
}
/**
* Pull narration text out of the in-progress turn segments so it can be posted
* as a durable mid-turn message. Tool/step blocks stay for the final publish.
*/
/** clientNonce prefix for mid-turn progress messages (reconciler uses this). */
export const USER_PROGRESS_CLIENT_NONCE_PREFIX = "user-progress:";
export function userProgressClientNonce(runId: string, index = 0): string {
// Include entropy so a resumed executor turn cannot reuse an earlier nonce
// (threadId + clientNonce is unique).
return `${USER_PROGRESS_CLIENT_NONCE_PREFIX}${runId}:${index}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
}
export function isUserProgressClientNonce(clientNonce: string | null | undefined): boolean {
return Boolean(clientNonce?.startsWith(USER_PROGRESS_CLIENT_NONCE_PREFIX));
}
export function extractNarrationText(
segments: readonly MessageBlock[],
currentText: string,
): { text: string; remaining: MessageBlock[] } {
const parts: string[] = [];
const remaining: MessageBlock[] = [];
for (const block of segments) {
if (block.kind === "text") {
if (block.text) parts.push(block.text);
continue;
}
remaining.push(block);
}
if (currentText) parts.push(currentText);
return { text: parts.join(""), remaining };
}
/**
* After mid-turn progress messages were already posted, skip a hollow final
* message that would only carry hidden tool-activity blocks (or nothing).
*/
export function finalBlocksAfterMidTurnProgress(
blocks: MessageBlock[],
publishedMidTurn: boolean,
): MessageBlock[] {
if (!publishedMidTurn || blocks.length === 0) return blocks;
if (blocks.every((block) => isToolActivityBlock(block))) return [];
return blocks;
}
/** Outcome to return for a bot_message run after mid-turn progress posts. */
export function botMessageOutcomeFromMidTurn(
finalText: string,
midTurnTexts: readonly string[],
): { text: string; intent: "result" | "status" } | null {
const trimmed = finalText.trim();
if (trimmed) return { text: trimmed, intent: "result" };
const midTurn = midTurnTexts.map((part) => part.trim()).filter(Boolean);
if (midTurn.length === 0) return null;
return { text: midTurn.join("\n\n"), intent: "status" };
}