murashit_codex-panel/src/app-server/protocol/turn.ts

145 lines
6.1 KiB
TypeScript

import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
import type { VaultFileReference } from "../../domain/chat/input";
import { type ReferencedThreadMetadata, referencedThreadMetadataFromPrompt } from "../../domain/threads/reference";
import {
nonEmptyTurnTranscriptSummaries,
type ThreadTranscriptEntry,
type TurnTranscriptSummary,
turnTranscriptSummaryFromTranscriptEntries,
} from "../../domain/threads/transcript";
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
export type TurnItem = GeneratedThreadItem;
export type TurnRecord = GeneratedTurn;
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
}
export function transcriptEntriesFromTurnRecords(turns: readonly TurnRecord[]): ThreadTranscriptEntry[] {
return turns.flatMap(transcriptEntriesFromTurnRecord);
}
function turnTranscriptSummaryFromTurnRecord(turn: TurnRecord): TurnTranscriptSummary {
return turnTranscriptSummaryFromTranscriptEntries(transcriptEntriesFromTurnRecord(turn));
}
export function turnTranscriptAssistantTextFromTurnRecord(turn: TurnRecord): string | null {
return turnTranscriptSummaryFromTurnRecord(turn).assistantText;
}
export function completedTurnTranscriptSummaryFromTurnRecord(turn: TurnRecord): TurnTranscriptSummary | null {
if (turn.status !== "completed") return null;
const summary = turnTranscriptSummaryFromTurnRecord(turn);
return summary.userText && summary.assistantText ? summary : null;
}
export function completedTurnTranscriptSummariesFromTurnRecords(turns: readonly TurnRecord[]): TurnTranscriptSummary[] {
return turns.flatMap((turn) => {
const summary = completedTurnTranscriptSummaryFromTurnRecord(turn);
return summary ? [summary] : [];
});
}
export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: readonly TurnRecord[]): TurnTranscriptSummary[] {
return nonEmptyTurnTranscriptSummaries(
[...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)).map(turnTranscriptSummaryFromTurnRecord),
);
}
export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = userMessageContextProjection(item.content, item.clientId);
const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
const legacyReference = referencedThreadMetadataFromPrompt(text);
return {
text: legacyReference?.text ?? text,
referencedThread: referencedThreadFromManifest(projected.manifest) ?? legacyReference?.reference ?? null,
manifest: projected.manifest,
};
}
export function turnUserFileReferences(
item: Extract<TurnItem, { type: "userMessage" }>,
manifest: ReturnType<typeof userMessageContextProjection>["manifest"],
): VaultFileReference[] {
const legacyReferences = item.content.flatMap((input) => {
if (input.type !== "mention") return [];
const reference = legacyPanelFileReference(input);
return reference ? [reference] : [];
});
return [...fileReferencesFromManifest(manifest), ...legacyReferences];
}
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
for (let index = turn.items.length - 1; index >= 0; index -= 1) {
const item = turn.items[index];
if (item === undefined) continue;
if (item.type !== "agentMessage") continue;
const text = item.text.trim();
if (text) return text;
}
return null;
}
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
if (item.type === "userMessage") {
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts =
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
return text
? [
{
kind: "user",
text,
timestamp: turn.startedAt,
...(projection.referencedThread ? { referencedThread: projection.referencedThread } : {}),
...(contexts.length > 0 ? { contexts } : {}),
},
]
: [];
}
if (item.type === "agentMessage") {
const text = item.text.trim();
return text ? [{ kind: "assistant", text, timestamp: turn.completedAt ?? turn.startedAt }] : [];
}
if (item.type === "plan") {
const text = item.text.trim();
return text ? [{ kind: "plan", text, timestamp: turn.completedAt ?? turn.startedAt }] : [];
}
return [];
}
function nonTextUserInputText(content: Extract<TurnItem, { type: "userMessage" }>["content"], visibleText: string): string {
const hasText = visibleText.length > 0;
const textIncludes = (value: string) => value.length > 0 && visibleText.includes(value);
return content
.map((item) => {
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
if (item.type === "mention") {
if (hasText) return "";
const fileReference = legacyPanelFileReference(item);
return fileReference ? `[file] ${fileReference.path}` : `[@${item.name}] ${item.path}`;
}
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function legacyPanelFileReference(input: { name: string; path: string }): VaultFileReference | null {
if (!input.path || /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input.path)) return null;
return { name: input.name, path: input.path };
}