src / analysisHandoff.ts
src / analysisHandoff.ts
import type { Chat } from "@lmstudio/sdk";
import { createHash } from "node:crypto";
import { realpath } from "node:fs/promises";
import { resolve, relative, isAbsolute } from "node:path";
import { imageMime, readImage, TOTAL_IMAGE_LIMIT } from "./images";
import type { Message } from "./messages";
const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
export async function completeDeferredAnalyses(
history: Chat, messages: Message[], getRoot: () => string, signal: AbortSignal,
analyze: (imageUrl: string, prompt: string) => Promise<string>,
): Promise<Message[]> {
let calls = new Set<string>();
let pending: any[] = [];
for (const message of history) {
// Only the most recent tool round is eligible. Older results must not keep
// reattaching images after the assistant has already analyzed them.
if (message.getRole() === "user" || message.getRole() === "assistant") {
pending = [];
calls = new Set(message.getToolCallRequests().filter(call =>
call.name.replace(/[^a-z]/gi, "").toLowerCase().endsWith("analyzelocalimageadvanced")
).map(call => call.id).filter((id): id is string => Boolean(id)));
}
if (message.getRole() !== "tool") continue;
for (const result of message.getToolCallResults()) {
if (!result.toolCallId || !calls.has(result.toolCallId)) continue;
let value;
try { value = JSON.parse(result.content); } catch { continue; }
if (value.analysisDeferred !== true || value._aiaStrataAnalysis?.version !== 1) continue;
pending.push({ value, toolCallId: result.toolCallId });
}
}
if (!pending.length) return messages;
const root = await realpath(getRoot());
const rootHash = digest(Buffer.from(root));
let total = 0;
const completed = new Map<string, string>();
for (const { value, toolCallId } of pending) {
signal.throwIfAborted();
const candidate = value._aiaImageCandidate;
const prompt = value._aiaStrataAnalysis.prompt;
if (candidate?.version !== 1 || candidate.rootHash !== rootHash || typeof candidate.relativePath !== "string"
|| typeof prompt !== "string" || !prompt.trim() || prompt.length > 128 * 1024
|| !/^[a-f0-9]{64}$/.test(candidate.sha256) || !Number.isFinite(candidate.createdAt)
|| Date.now() - candidate.createdAt > 24 * 60 * 60 * 1000 || candidate.createdAt > Date.now() + 60_000
|| isAbsolute(candidate.relativePath)) throw new Error("Invalid deferred image-analysis receipt.");
const target = await realpath(resolve(root, candidate.relativePath));
const rel = relative(root, target);
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) throw new Error("Deferred image escapes the chat working directory.");
const bytes = await readImage(target, signal);
if (digest(bytes) !== candidate.sha256) throw new Error("Deferred image changed after tool verification. Run Analyze again.");
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Deferred analysis images exceed 64 MiB.");
// Complete the analysis in an isolated request. Its output is evidence for
// this exact tool call, never a new user instruction in the main dialogue.
const analysis = await analyze(`data:${imageMime(bytes)};base64,${bytes.toString("base64")}`, prompt);
if (!analysis.trim()) throw new Error("Strata returned an empty image analysis.");
completed.set(toolCallId, JSON.stringify({ analysis, _aiaImageCandidate: candidate }));
}
return messages.map(message => message.role === "tool" && message.tool_call_id && completed.has(message.tool_call_id)
? { ...message, content: completed.get(message.tool_call_id)! } : message);
}
import type { Chat } from "@lmstudio/sdk";
import { createHash } from "node:crypto";
import { realpath } from "node:fs/promises";
import { resolve, relative, isAbsolute } from "node:path";
import { imageMime, readImage, TOTAL_IMAGE_LIMIT } from "./images";
import type { Message } from "./messages";
const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
export async function completeDeferredAnalyses(
history: Chat, messages: Message[], getRoot: () => string, signal: AbortSignal,
analyze: (imageUrl: string, prompt: string) => Promise<string>,
): Promise<Message[]> {
let calls = new Set<string>();
let pending: any[] = [];
for (const message of history) {
// Only the most recent tool round is eligible. Older results must not keep
// reattaching images after the assistant has already analyzed them.
if (message.getRole() === "user" || message.getRole() === "assistant") {
pending = [];
calls = new Set(message.getToolCallRequests().filter(call =>
call.name.replace(/[^a-z]/gi, "").toLowerCase().endsWith("analyzelocalimageadvanced")
).map(call => call.id).filter((id): id is string => Boolean(id)));
}
if (message.getRole() !== "tool") continue;
for (const result of message.getToolCallResults()) {
if (!result.toolCallId || !calls.has(result.toolCallId)) continue;
let value;
try { value = JSON.parse(result.content); } catch { continue; }
if (value.analysisDeferred !== true || value._aiaStrataAnalysis?.version !== 1) continue;
pending.push({ value, toolCallId: result.toolCallId });
}
}
if (!pending.length) return messages;
const root = await realpath(getRoot());
const rootHash = digest(Buffer.from(root));
let total = 0;
const completed = new Map<string, string>();
for (const { value, toolCallId } of pending) {
signal.throwIfAborted();
const candidate = value._aiaImageCandidate;
const prompt = value._aiaStrataAnalysis.prompt;
if (candidate?.version !== 1 || candidate.rootHash !== rootHash || typeof candidate.relativePath !== "string"
|| typeof prompt !== "string" || !prompt.trim() || prompt.length > 128 * 1024
|| !/^[a-f0-9]{64}$/.test(candidate.sha256) || !Number.isFinite(candidate.createdAt)
|| Date.now() - candidate.createdAt > 24 * 60 * 60 * 1000 || candidate.createdAt > Date.now() + 60_000
|| isAbsolute(candidate.relativePath)) throw new Error("Invalid deferred image-analysis receipt.");
const target = await realpath(resolve(root, candidate.relativePath));
const rel = relative(root, target);
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) throw new Error("Deferred image escapes the chat working directory.");
const bytes = await readImage(target, signal);
if (digest(bytes) !== candidate.sha256) throw new Error("Deferred image changed after tool verification. Run Analyze again.");
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Deferred analysis images exceed 64 MiB.");
// Complete the analysis in an isolated request. Its output is evidence for
// this exact tool call, never a new user instruction in the main dialogue.
const analysis = await analyze(`data:${imageMime(bytes)};base64,${bytes.toString("base64")}`, prompt);
if (!analysis.trim()) throw new Error("Strata returned an empty image analysis.");
completed.set(toolCallId, JSON.stringify({ analysis, _aiaImageCandidate: candidate }));
}
return messages.map(message => message.role === "tool" && message.tool_call_id && completed.has(message.tool_call_id)
? { ...message, content: completed.get(message.tool_call_id)! } : message);
}