Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
|
||||
function desktopIPCConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
|
||||
function bridge(): BridgeState {
|
||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||
if (!global[key]) {
|
||||
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||
global[key] = state;
|
||||
process.on("message", (raw: unknown) => {
|
||||
const receive = (raw: unknown) => {
|
||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
||||
if (m?.channel !== "lh:chatgpt") return;
|
||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
|
||||
if (m.ok === true) call.resolve(m.value);
|
||||
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||
}
|
||||
});
|
||||
};
|
||||
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
|
||||
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
|
||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||
}
|
||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
|
||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
|
||||
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
|
||||
});
|
||||
}
|
||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
||||
}
|
||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||
const s = bridge();
|
||||
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
}
|
||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
|
||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
||||
if (!runtimeConnected()) { clearCatalog(state); return; }
|
||||
const sequence = ++state.catalogRequest;
|
||||
try {
|
||||
const value = await chatGPTRequest<ModelsValue>("models");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
type BinaryLookup = {
|
||||
env: Record<string, string | undefined>;
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
cwd?: string;
|
||||
isExecutable: (path: string) => boolean;
|
||||
};
|
||||
|
||||
type SearchSpec = {
|
||||
paths: path.PlatformPath;
|
||||
names: string[];
|
||||
knownDirs: string[];
|
||||
};
|
||||
|
||||
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||
const paths = path.win32;
|
||||
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
|
||||
|
||||
return {
|
||||
paths,
|
||||
// Direct spawn deliberately uses no shell. A .cmd-only npm install
|
||||
// is not runnable and must not count as an available provider.
|
||||
names: ["claude.exe"],
|
||||
knownDirs: [
|
||||
paths.join(homeDir, ".local", "bin"),
|
||||
paths.join(homeDir, ".claude", "local"),
|
||||
env.npm_config_prefix ?? paths.join(appData, "npm"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||
const paths = path.posix;
|
||||
const npmBin = env.npm_config_prefix
|
||||
? paths.join(env.npm_config_prefix, "bin")
|
||||
: paths.join(homeDir, ".npm-global", "bin");
|
||||
|
||||
return {
|
||||
paths,
|
||||
names: ["claude"],
|
||||
knownDirs: [
|
||||
paths.join(homeDir, ".local", "bin"),
|
||||
paths.join(homeDir, ".claude", "local"),
|
||||
npmBin,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null {
|
||||
const { paths, names, knownDirs } = platform === "win32"
|
||||
? getWindowsSearchSpec(env, homeDir)
|
||||
: getPosixSearchSpec(env, homeDir);
|
||||
|
||||
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
|
||||
|
||||
for (const name of names) {
|
||||
for (const dir of [...pathDirs, ...knownDirs]) {
|
||||
// Discovery is relative to the server cwd; probes/model work use
|
||||
// different directories and must receive the same absolute identity.
|
||||
const candidate = paths.resolve(cwd, dir, name);
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeBinary(): string | null {
|
||||
return resolveClaudeBinary({
|
||||
env: process.env,
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
isExecutable: isExecutableFile,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
|
||||
export type CliUsage = Partial<Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">>;
|
||||
|
||||
export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string };
|
||||
|
||||
export type CliEvent =
|
||||
| { type: "messageStart"; usage: CliUsage }
|
||||
| { type: "blockStart"; index: number; block: CliBlock }
|
||||
| { type: "textDelta"; index: number; text: string }
|
||||
| { type: "thinkingDelta"; index: number; thinking: string }
|
||||
| { type: "toolInputDelta"; index: number; partialJson: string }
|
||||
| { type: "blockStop"; index: number }
|
||||
| { type: "messageDelta"; stopReason: string | null; usage: CliUsage }
|
||||
| { type: "messageStop" }
|
||||
| { type: "assistantError"; error: string; text: string }
|
||||
| { type: "resultError"; text: string }
|
||||
| { type: "toolResults"; ids: string[] };
|
||||
|
||||
type RawUsage = {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
};
|
||||
|
||||
type RawContentBlock = { type: string; id?: string; name?: string };
|
||||
|
||||
type RawDelta = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
partial_json?: string;
|
||||
stop_reason?: string | null;
|
||||
};
|
||||
|
||||
type RawStreamEvent = {
|
||||
type: string;
|
||||
index?: number;
|
||||
message?: { usage?: RawUsage };
|
||||
content_block?: RawContentBlock;
|
||||
delta?: RawDelta;
|
||||
usage?: RawUsage;
|
||||
};
|
||||
|
||||
type RawCliLine = {
|
||||
type?: string;
|
||||
event?: RawStreamEvent;
|
||||
error?: string | null;
|
||||
message?: { content?: { type: string; text?: string; tool_use_id?: string }[] };
|
||||
is_error?: boolean;
|
||||
result?: string;
|
||||
subtype?: string;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
function toUsage(raw: RawUsage): CliUsage {
|
||||
const usage: CliUsage = {};
|
||||
|
||||
if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens;
|
||||
if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens;
|
||||
if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens;
|
||||
if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens;
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
function toBlock(raw: RawContentBlock): CliBlock | null {
|
||||
switch (raw.type) {
|
||||
case "text":
|
||||
return { type: "text" };
|
||||
|
||||
case "thinking":
|
||||
return { type: "thinking" };
|
||||
|
||||
case "tool_use":
|
||||
if (typeof raw.id !== "string" || typeof raw.name !== "string") return null;
|
||||
return { type: "toolUse", id: raw.id, name: raw.name };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDelta(index: number, delta: RawDelta): CliEvent | null {
|
||||
switch (delta.type) {
|
||||
case "text_delta":
|
||||
if (typeof delta.text !== "string") return null;
|
||||
return { type: "textDelta", index, text: delta.text };
|
||||
|
||||
case "thinking_delta":
|
||||
if (typeof delta.thinking !== "string") return null;
|
||||
return { type: "thinkingDelta", index, thinking: delta.thinking };
|
||||
|
||||
case "input_json_delta":
|
||||
if (typeof delta.partial_json !== "string") return null;
|
||||
return { type: "toolInputDelta", index, partialJson: delta.partial_json };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null {
|
||||
switch (event.type) {
|
||||
case "content_block_start": {
|
||||
if (!event.content_block) return null;
|
||||
|
||||
const block = toBlock(event.content_block);
|
||||
return block ? { type: "blockStart", index, block } : null;
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
return event.delta ? parseDelta(index, event.delta) : null;
|
||||
|
||||
case "content_block_stop":
|
||||
return { type: "blockStop", index };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStreamEvent(event: RawStreamEvent): CliEvent | null {
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) };
|
||||
|
||||
case "message_delta": {
|
||||
const stopReason = event.delta?.stop_reason ?? null;
|
||||
return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) };
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
return { type: "messageStop" };
|
||||
|
||||
default:
|
||||
return event.index === undefined ? null : parseBlockEvent(event.index, event);
|
||||
}
|
||||
}
|
||||
|
||||
function getText(line: RawCliLine): string {
|
||||
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
|
||||
const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : []));
|
||||
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
function getResultErrorText(line: RawCliLine): string {
|
||||
if (line.result) return line.result;
|
||||
if (line.errors?.length) return line.errors.join("\n");
|
||||
return `claude CLI failed: ${line.subtype ?? "unknown error"}`;
|
||||
}
|
||||
|
||||
export function parseCliLine(text: string): CliEvent | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
|
||||
const line = parsed as RawCliLine;
|
||||
|
||||
switch (line.type) {
|
||||
case "stream_event":
|
||||
return line.event ? parseStreamEvent(line.event) : null;
|
||||
|
||||
case "assistant":
|
||||
if (typeof line.error !== "string") return null;
|
||||
return { type: "assistantError", error: line.error, text: getText(line) };
|
||||
|
||||
case "user": {
|
||||
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
|
||||
const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []);
|
||||
return ids.length ? { type: "toolResults", ids } : null;
|
||||
}
|
||||
|
||||
case "result":
|
||||
if (line.is_error !== true) return null;
|
||||
return { type: "resultError", text: getResultErrorText(line) };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts";
|
||||
|
||||
export const CLAUDE_CODE_API = "claude-code-cli";
|
||||
|
||||
export enum ClaudeCodeError {
|
||||
CliNotFound = "claude CLI not found",
|
||||
NotSignedIn = "not signed in",
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const INITIALIZE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** A best-effort, local-only CLI control query. Wire contract matches the
|
||||
* official SDK0.3.259 source; this does not import or execute the SDK.
|
||||
* Summary deliberately avoids the default full mode's token-count requests.
|
||||
* CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */
|
||||
export class ContextWindowProbe {
|
||||
private readonly initializeId = randomUUID();
|
||||
private summaryId: string | undefined;
|
||||
private initialized = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private ready = false;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly write: (line: string) => void,
|
||||
private readonly sendPrompt: () => void,
|
||||
private readonly onWindow: (tokens: number) => void,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS);
|
||||
this.timer.unref();
|
||||
this.request(this.initializeId, { subtype: "initialize" });
|
||||
}
|
||||
|
||||
/** Consume control replies separately from model/tool output, including
|
||||
* late/foreign replies while a native question tool is already waiting. */
|
||||
handle(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false;
|
||||
if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true;
|
||||
const response = value.response as Record<string, unknown>;
|
||||
if (response.request_id === this.initializeId) this.proceed(response.subtype === "success");
|
||||
else if (this.summaryId && response.request_id === this.summaryId) {
|
||||
this.summaryId = undefined;
|
||||
if (response.subtype !== "success") return true;
|
||||
const body = response.response as Record<string, unknown> | undefined;
|
||||
const tokens = body?.maxTokens;
|
||||
if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Retry when the CLI has begun a model response: pre-first-turn metadata
|
||||
* is not guaranteed by the protocol. Refresh also follows later responses
|
||||
* within the same held process, without opening a second process. */
|
||||
refresh(): void {
|
||||
if (!this.initialized || this.closed) return;
|
||||
this.summaryId = randomUUID();
|
||||
this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" });
|
||||
}
|
||||
|
||||
close(): void { this.closed = true; clearTimeout(this.timer); }
|
||||
|
||||
private proceed(query: boolean): void {
|
||||
if (this.ready || this.closed) return;
|
||||
this.ready = true;
|
||||
clearTimeout(this.timer);
|
||||
this.initialized = query;
|
||||
if (query) this.refresh();
|
||||
// Never wait for metadata to run the actual user request. No support,
|
||||
// no response, or malformed metadata simply leaves the window unknown.
|
||||
this.sendPrompt();
|
||||
}
|
||||
|
||||
private request(id: string, request: Record<string, string>): void {
|
||||
this.write(JSON.stringify({ type: "control_request", request_id: id, request }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { externalCliEnv } from "../../core/auth-env.ts";
|
||||
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
|
||||
import { getClaudeBinary } from "./binary.ts";
|
||||
|
||||
export type ReadClaudeVersion = (binary: string) => Promise<string>;
|
||||
|
||||
/** No shell, auth-status command, model call, or credential-file inspection. */
|
||||
export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => {
|
||||
execFile(binary, ["--version"], {
|
||||
cwd: tmpdir(), env: externalCliEnv(), windowsHide: true,
|
||||
timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096,
|
||||
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
||||
});
|
||||
|
||||
export async function inspectClaudeInstallation(
|
||||
findBinary: () => string | null = getClaudeBinary,
|
||||
readVersion: ReadClaudeVersion = readClaudeVersion,
|
||||
): Promise<ClaudeCodeStatus> {
|
||||
const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION };
|
||||
const binary = findBinary();
|
||||
if (!binary) return { ...base, state: "not-found" };
|
||||
try {
|
||||
const output = await readVersion(binary);
|
||||
// Expose only a version, never arbitrary CLI stdout/stderr or an absolute path.
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim());
|
||||
if (!match) return { ...base, state: "version-unchecked" };
|
||||
const parts = match.slice(1).map(Number);
|
||||
if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" };
|
||||
const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number);
|
||||
const differing = parts.findIndex((part, index) => part !== minimum[index]);
|
||||
const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!;
|
||||
return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" };
|
||||
} catch {
|
||||
return { ...base, state: "version-unchecked" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { getClaudeBinary } from "./binary.ts";
|
||||
import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts";
|
||||
import type { RunClaude } from "./stream.ts";
|
||||
|
||||
export class ClaudeLaunchError extends Error {}
|
||||
|
||||
/** Injectable seams keep all verification offline; production uses the installed CLI. */
|
||||
export function createClaudeLauncher({
|
||||
findBinary = getClaudeBinary,
|
||||
readVersion = readClaudeVersion,
|
||||
spawnProcess = spawn,
|
||||
}: {
|
||||
findBinary?: () => string | null;
|
||||
readVersion?: ReadClaudeVersion;
|
||||
spawnProcess?: typeof spawn;
|
||||
} = {}): RunClaude {
|
||||
return async (args, options) => {
|
||||
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
|
||||
const binary = findBinary();
|
||||
if (!binary) return null;
|
||||
const status = await inspectClaudeInstallation(() => binary, readVersion);
|
||||
if (status.state === "update-required") {
|
||||
throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`);
|
||||
}
|
||||
if (status.state !== "meets-minimum") {
|
||||
throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again.");
|
||||
}
|
||||
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
|
||||
return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void };
|
||||
|
||||
// Tools usually call back within seconds, but a question tool holds its call until the learner
|
||||
// answers. Past this, the process is killed and the answer starts a fresh one with a full replay.
|
||||
// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own.
|
||||
export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Test seam (runner.ts's pdfModelOverride idiom). */
|
||||
export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS };
|
||||
|
||||
// Each live process mapped to the timer that kills it if pi never calls back.
|
||||
const liveProcesses = new Map<LiveProcess, NodeJS.Timeout>();
|
||||
|
||||
export function addLiveProcess(process: LiveProcess): void {
|
||||
removeLiveProcess(process);
|
||||
const timer = setTimeout(() => {
|
||||
removeLiveProcess(process);
|
||||
process.kill();
|
||||
}, liveProcessIdleTimeoutMs.current);
|
||||
|
||||
timer.unref();
|
||||
|
||||
liveProcesses.set(process, timer);
|
||||
}
|
||||
|
||||
function hasSameIds(ids: string[], otherIds: string[]): boolean {
|
||||
return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
|
||||
}
|
||||
|
||||
export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null {
|
||||
if (!sessionId || toolCallIds.length === 0) return null;
|
||||
|
||||
for (const process of liveProcesses.keys()) {
|
||||
if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeLiveProcess(process: LiveProcess): void {
|
||||
clearTimeout(liveProcesses.get(process));
|
||||
liveProcesses.delete(process);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai";
|
||||
|
||||
import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts";
|
||||
|
||||
const SHARED_MODEL_FIELDS = {
|
||||
api: CLAUDE_CODE_API,
|
||||
provider: CLAUDE_CODE_PROVIDER,
|
||||
baseUrl: "",
|
||||
input: ["text", "image"],
|
||||
// Pi-required placeholders, not prices: the CLI's own auth/routing
|
||||
// determines charges. Server pricing and the client catalog report unpriced.
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
reasoning: true,
|
||||
// The CLI has no thinking-off switch; effort low is the floor.
|
||||
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
} satisfies Omit<Model<Api>, "id" | "name">;
|
||||
|
||||
export const CLAUDE_CODE_MODELS: readonly Model<Api>[] = [
|
||||
{ ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" },
|
||||
{ ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" },
|
||||
{ ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" },
|
||||
];
|
||||
|
||||
export function createClaudeCodeProvider(streamSimple: StreamFunction<Api, SimpleStreamOptions>): Provider {
|
||||
return createProvider({
|
||||
id: CLAUDE_CODE_PROVIDER,
|
||||
name: "Claude Code",
|
||||
auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } },
|
||||
models: CLAUDE_CODE_MODELS,
|
||||
api: { stream: streamSimple, streamSimple },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||
|
||||
/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */
|
||||
export function conversationForReuse(messages: Message[]): unknown[] {
|
||||
return messages.flatMap((message): unknown[] => {
|
||||
switch (message.role) {
|
||||
case "system": return [];
|
||||
case "user": return [{ role: message.role, content: message.content }];
|
||||
case "assistant": return [{ role: message.role, content: message.content }];
|
||||
case "toolResult": return [{
|
||||
role: message.role,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
content: message.content,
|
||||
isError: message.isError,
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function configurationForReuse(model: Model<Api>, context: TranscriptContext, effort: string): unknown {
|
||||
const systemPrompt = getCurrentSystemPrompt(context.messages);
|
||||
const tools = getCurrentTools(context.messages);
|
||||
// The wire is JSON: TypeBox's schema symbols are not CLI configuration.
|
||||
return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown;
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import { type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
createAssistantMessageEventStream,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Message,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type StreamFunction,
|
||||
type ThinkingLevel,
|
||||
type ToolCall,
|
||||
type ToolResultMessage,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
|
||||
|
||||
import { externalCliEnv } from "../../core/auth-env.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts";
|
||||
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
|
||||
import { ClaudeCodeError } from "./constants.ts";
|
||||
import { ContextWindowProbe } from "./context-window.ts";
|
||||
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
|
||||
import { configurationForReuse, conversationForReuse } from "./reuse-context.ts";
|
||||
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
|
||||
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
|
||||
|
||||
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
|
||||
|
||||
type ClaudeChild = ChildProcessWithoutNullStreams | null;
|
||||
export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise<ClaudeChild>;
|
||||
|
||||
// A held MCP call lasts until pi calls back with the tool result, which for a question tool is
|
||||
// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never
|
||||
// times out first.
|
||||
const MCP_TOOL_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
const PRIVATE_FILE_MODE = 0o600;
|
||||
|
||||
const EFFORTS: Record<ThinkingLevel, AnthropicEffort> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
};
|
||||
|
||||
const STOP_REASONS: Record<string, StopReason> = {
|
||||
end_turn: "stop",
|
||||
pause_turn: "stop",
|
||||
stop_sequence: "stop",
|
||||
max_tokens: "length",
|
||||
tool_use: "toolUse",
|
||||
refusal: "error",
|
||||
};
|
||||
|
||||
const REFUSAL_MESSAGE = "The model refused to complete the request";
|
||||
const ABORT_MESSAGE = "Request was aborted";
|
||||
const START_FAILED_MESSAGE = "claude CLI failed to start";
|
||||
const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason";
|
||||
const NOT_SIGNED_IN_ERROR = "authentication_failed";
|
||||
|
||||
export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort {
|
||||
return level ? EFFORTS[level] : "medium";
|
||||
}
|
||||
|
||||
type ClaudeArgsInput = {
|
||||
model: string;
|
||||
effort: AnthropicEffort;
|
||||
systemPromptFile: string;
|
||||
mcpConfigFile: string;
|
||||
allowedTools: string[];
|
||||
};
|
||||
|
||||
export function toClaudeArgs(input: ClaudeArgsInput): string[] {
|
||||
return [
|
||||
"-p",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--model",
|
||||
input.model,
|
||||
"--effort",
|
||||
input.effort,
|
||||
"--system-prompt-file",
|
||||
input.systemPromptFile,
|
||||
"--tools",
|
||||
"",
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
input.mcpConfigFile,
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--settings",
|
||||
JSON.stringify({ disableAllHooks: true }),
|
||||
"--disable-slash-commands",
|
||||
"--no-session-persistence",
|
||||
"--permission-prompts",
|
||||
"none",
|
||||
...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function createOutput(model: Model<Api>): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantErrorMessage(error: string, text: string): string {
|
||||
return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error;
|
||||
}
|
||||
|
||||
class ClaudeTurn {
|
||||
private readonly output: AssistantMessage & Pick<LhMessage, "cliContextWindow">;
|
||||
private readonly contentIndexes = new Map<number, number>();
|
||||
private readonly partialJson = new Map<number, string>();
|
||||
private readonly toolBlocks = new Map<number, ToolCall>();
|
||||
private readonly intents: ToolCall[] = [];
|
||||
started = false;
|
||||
finished = false;
|
||||
|
||||
constructor(
|
||||
private readonly stream: AssistantMessageEventStream,
|
||||
model: Model<Api>,
|
||||
) {
|
||||
this.output = createOutput(model);
|
||||
}
|
||||
|
||||
handle(event: CliEvent): void {
|
||||
if (this.finished) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "messageStart":
|
||||
this.updateUsage(event.usage);
|
||||
this.start();
|
||||
return;
|
||||
|
||||
case "blockStart":
|
||||
this.startBlock(event.index, event.block);
|
||||
return;
|
||||
|
||||
case "textDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "text") return;
|
||||
|
||||
block.text += event.text;
|
||||
this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "thinkingDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "thinking") return;
|
||||
|
||||
block.thinking += event.thinking;
|
||||
this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "toolInputDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { block } = found;
|
||||
if (block.type !== "toolCall") return;
|
||||
|
||||
const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson;
|
||||
this.partialJson.set(event.index, partialJson);
|
||||
block.arguments = parseStreamingJson(partialJson);
|
||||
return;
|
||||
}
|
||||
|
||||
case "blockStop":
|
||||
this.stopBlock(event.index);
|
||||
return;
|
||||
|
||||
case "messageDelta":
|
||||
this.updateUsage(event.usage);
|
||||
if (event.stopReason) this.setStopReason(event.stopReason);
|
||||
return;
|
||||
|
||||
case "messageStop":
|
||||
// Tool intent is not execution admission. The process publishes only
|
||||
// calls that actually reach the authenticated MCP bridge.
|
||||
if (this.output.stopReason !== "toolUse") this.finish();
|
||||
return;
|
||||
|
||||
case "assistantError":
|
||||
this.fail(getAssistantErrorMessage(event.error, event.text), "error");
|
||||
return;
|
||||
|
||||
case "resultError":
|
||||
this.fail(event.text, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; }
|
||||
|
||||
getIntents(): ToolCall[] { return this.intents; }
|
||||
|
||||
getMessage(): AssistantMessage { return this.output; }
|
||||
|
||||
setContextWindow(tokens: number | undefined): void {
|
||||
if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens;
|
||||
}
|
||||
|
||||
admit(calls: ToolCall[]): void {
|
||||
this.start();
|
||||
for (const call of calls) {
|
||||
const contentIndex = this.output.content.length;
|
||||
this.output.content.push(call);
|
||||
this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
|
||||
this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output });
|
||||
}
|
||||
this.output.stopReason = "toolUse";
|
||||
this.finish();
|
||||
}
|
||||
|
||||
fail(message: string, reason: "aborted" | "error"): void {
|
||||
if (this.finished) return;
|
||||
|
||||
this.finished = true;
|
||||
this.output.stopReason = reason;
|
||||
this.output.errorMessage = message;
|
||||
this.stream.push({ type: "error", reason, error: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (this.started) return;
|
||||
|
||||
this.started = true;
|
||||
this.stream.push({ type: "start", partial: this.output });
|
||||
}
|
||||
|
||||
private startBlock(index: number, block: CliBlock): void {
|
||||
const contentIndex = this.output.content.length;
|
||||
this.contentIndexes.set(index, contentIndex);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.output.content.push({ type: "text", text: "" });
|
||||
this.stream.push({ type: "text_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.output.content.push({ type: "thinking", thinking: "" });
|
||||
this.stream.push({ type: "thinking_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolUse":
|
||||
if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) {
|
||||
this.fail("claude CLI emitted an unsupported native tool call", "error");
|
||||
return;
|
||||
}
|
||||
this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null {
|
||||
const tool = this.toolBlocks.get(index);
|
||||
if (tool) return { contentIndex: -1, block: tool };
|
||||
const contentIndex = this.contentIndexes.get(index);
|
||||
if (contentIndex === undefined) return null;
|
||||
|
||||
const block = this.output.content[contentIndex];
|
||||
return block ? { contentIndex, block } : null;
|
||||
}
|
||||
|
||||
private stopBlock(index: number): void {
|
||||
const found = this.getBlock(index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
this.contentIndexes.delete(index);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolCall":
|
||||
Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? ""));
|
||||
this.partialJson.delete(index);
|
||||
this.toolBlocks.delete(index);
|
||||
this.intents.push(block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private updateUsage(usage: CliUsage): void {
|
||||
Object.assign(this.output.usage, usage);
|
||||
|
||||
const { input, output, cacheRead, cacheWrite } = this.output.usage;
|
||||
this.output.usage.totalTokens = input + output + cacheRead + cacheWrite;
|
||||
}
|
||||
|
||||
private setStopReason(stopReason: string): void {
|
||||
const mapped = STOP_REASONS[stopReason];
|
||||
|
||||
if (!mapped) {
|
||||
this.output.stopReason = "error";
|
||||
this.output.errorMessage = `Unhandled stop reason: ${stopReason}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.output.stopReason = mapped;
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
const { stopReason } = this.output;
|
||||
|
||||
if (stopReason === "error") {
|
||||
this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopReason === "pending" || stopReason === "aborted") {
|
||||
this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this.finished = true;
|
||||
this.stream.push({ type: "done", reason: stopReason, message: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
}
|
||||
|
||||
type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string };
|
||||
|
||||
async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise<TurnFiles> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-"));
|
||||
let privateDir: string | undefined;
|
||||
try {
|
||||
privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-"));
|
||||
const systemPromptFile = join(privateDir, "system-prompt.md");
|
||||
const mcpConfigFile = join(privateDir, "mcp.json");
|
||||
await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE });
|
||||
await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE });
|
||||
return { cwd, privateDir, systemPromptFile, mcpConfigFile };
|
||||
} catch (error) {
|
||||
await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise<void> {
|
||||
await Promise.all([
|
||||
bridge.close(),
|
||||
rm(files.cwd, { recursive: true, force: true }),
|
||||
rm(files.privateDir, { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
class ClaudeProcess implements LiveProcess {
|
||||
toolCallIds: string[] = [];
|
||||
private stderr = "";
|
||||
private closed = false;
|
||||
private stopping = false;
|
||||
private killTimer: NodeJS.Timeout | undefined;
|
||||
private signal: AbortSignal | undefined;
|
||||
private sourceCalls: ToolCall[] = [];
|
||||
private sourceComplete = false;
|
||||
private admitted: PendingToolCall[] = [];
|
||||
private readonly cancelledIds = new Set<string>();
|
||||
private pendingError: string | undefined;
|
||||
private contextWindow: number | undefined;
|
||||
private contextProbe: ContextWindowProbe | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly bridge: ToolBridge,
|
||||
private readonly files: TurnFiles,
|
||||
private turn: ClaudeTurn,
|
||||
readonly owner: symbol,
|
||||
readonly sessionId: string | undefined,
|
||||
private readonly configuration: unknown,
|
||||
private conversation: unknown[],
|
||||
) {}
|
||||
|
||||
start(stdinLine: string, signal: AbortSignal | undefined): void {
|
||||
this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`));
|
||||
this.child.once("error", (error) => {
|
||||
console.error("claude CLI process failed:", error);
|
||||
this.turn.fail(START_FAILED_MESSAGE, "error");
|
||||
this.handleClose(START_FAILED_MESSAGE);
|
||||
});
|
||||
this.child.stderr.setEncoding("utf8");
|
||||
this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); });
|
||||
this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
|
||||
this.contextProbe = new ContextWindowProbe(
|
||||
(line) => this.child.stdin.write(`${line}\n`),
|
||||
() => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); },
|
||||
(tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); },
|
||||
);
|
||||
createInterface({ input: this.child.stdout }).on("line", (line) => {
|
||||
try { this.handleLine(line); }
|
||||
catch { this.fail("claude CLI emitted malformed stream output"); }
|
||||
});
|
||||
this.setSignal(signal);
|
||||
if (!this.stopping) this.contextProbe.start();
|
||||
}
|
||||
|
||||
canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
|
||||
const conversation = conversationForReuse(messages);
|
||||
return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId
|
||||
&& isDeepStrictEqual(configuration, this.configuration)
|
||||
&& isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation)
|
||||
&& conversation.length === this.conversation.length + this.admitted.length
|
||||
&& results.length === this.admitted.length
|
||||
&& new Set(results.map((result) => result.toolCallId)).size === results.length
|
||||
&& this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name));
|
||||
}
|
||||
|
||||
continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void {
|
||||
removeLiveProcess(this);
|
||||
const admitted = this.admitted;
|
||||
this.admitted = [];
|
||||
this.conversation = structuredClone(conversationForReuse(messages));
|
||||
this.turn = turn;
|
||||
this.turn.setContextWindow(this.contextWindow);
|
||||
this.sourceComplete = this.sourceCalls.length > 0;
|
||||
this.setSignal(signal);
|
||||
if (this.stopping) return;
|
||||
for (const call of admitted) {
|
||||
const result = results.find((candidate) => candidate.toolCallId === call.id)!;
|
||||
this.bridge.resolveToolCall(call, result);
|
||||
}
|
||||
this.flushAdmissions();
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
removeLiveProcess(this);
|
||||
if (this.stopping || this.closed) return;
|
||||
this.stopping = true;
|
||||
this.contextProbe?.close();
|
||||
this.child.kill();
|
||||
this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000);
|
||||
this.killTimer.unref();
|
||||
}
|
||||
|
||||
fail(message: string): void {
|
||||
// A parallel request can fail after the admitted slice was handed to
|
||||
// Pi. Retain that failure for its continuation instead of silently
|
||||
// replaying and hiding the compatibility error.
|
||||
if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
|
||||
this.turn.fail(message, "error");
|
||||
this.kill();
|
||||
if (this.pendingError) addLiveProcess(this);
|
||||
}
|
||||
|
||||
consumeFailure(owner: symbol, turn: ClaudeTurn): boolean {
|
||||
if (owner !== this.owner || !this.pendingError) return false;
|
||||
const message = this.pendingError;
|
||||
this.pendingError = undefined;
|
||||
this.kill();
|
||||
turn.fail(message, "error");
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelCall(call: PendingToolCall): void {
|
||||
this.cancelledIds.add(call.id);
|
||||
// Already admitted work belongs to Pi and cannot be undone. A queued
|
||||
// request, however, must never become executable after cancellation.
|
||||
this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id);
|
||||
}
|
||||
|
||||
/** Only authenticated MCP requests, never stdout intent alone, admit side effects. */
|
||||
flushAdmissions(): void {
|
||||
if (this.stopping || this.turn.finished || !this.sourceComplete) return;
|
||||
const pending = this.bridge.getPendingToolCalls();
|
||||
if (pending.length === 0) return;
|
||||
const calls: ToolCall[] = [];
|
||||
for (const call of pending) {
|
||||
const intent = this.sourceCalls.find((candidate) => candidate.id === call.id);
|
||||
if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) {
|
||||
this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent");
|
||||
return;
|
||||
}
|
||||
calls.push(intent);
|
||||
}
|
||||
this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id));
|
||||
this.admitted = pending;
|
||||
this.toolCallIds = pending.map((call) => call.id);
|
||||
this.turn.admit(calls);
|
||||
this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()])));
|
||||
if (this.sessionId) addLiveProcess(this);
|
||||
else this.kill(); // No trusted scope: return the batch, then use fresh replay.
|
||||
}
|
||||
|
||||
private readonly abort = () => {
|
||||
this.turn.fail(ABORT_MESSAGE, "aborted");
|
||||
this.kill();
|
||||
};
|
||||
|
||||
private setSignal(signal: AbortSignal | undefined): void {
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
this.signal = signal;
|
||||
signal?.addEventListener("abort", this.abort, { once: true });
|
||||
if (signal?.aborted) this.abort();
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (this.stopping || !line.trim()) return;
|
||||
if (this.contextProbe?.handle(JSON.parse(line))) return;
|
||||
const event = parseCliLine(line);
|
||||
if (!event) return;
|
||||
if (event.type === "toolResults") {
|
||||
if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) {
|
||||
this.fail("claude CLI refused a harness tool call before MCP admission");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Moving on before held calls were answered means the CLI did not
|
||||
// honor this tool wait. Never execute further stdout-only intentions.
|
||||
if (this.turn.finished) {
|
||||
this.fail("claude CLI moved on while harness tool calls were waiting for results");
|
||||
return;
|
||||
}
|
||||
if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) {
|
||||
this.fail("claude CLI moved on without admitting its harness tool calls");
|
||||
return;
|
||||
}
|
||||
if (event.type === "messageStart") this.contextProbe?.refresh();
|
||||
this.turn.handle(event);
|
||||
if (this.turn.finished) { this.kill(); return; }
|
||||
if (event.type === "messageStop" && this.turn.waitingForAdmission) {
|
||||
const intents = this.turn.getIntents();
|
||||
if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) {
|
||||
this.fail("claude CLI emitted an invalid tool batch");
|
||||
return;
|
||||
}
|
||||
this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id));
|
||||
this.sourceComplete = true;
|
||||
this.flushAdmissions();
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(message: string): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.contextProbe?.close();
|
||||
clearTimeout(this.killTimer);
|
||||
removeLiveProcess(this);
|
||||
if (this.pendingError) addLiveProcess(this);
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim());
|
||||
this.turn.fail(message, "error");
|
||||
removeTurnResources(this.bridge, this.files).catch((error: unknown) => {
|
||||
console.error("claude CLI cleanup failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
|
||||
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
|
||||
const assistant = messages[assistantIndex];
|
||||
if (assistant?.role !== "assistant") return false;
|
||||
const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []);
|
||||
const process = getLiveProcess(ids, owner, sessionId);
|
||||
if (!(process instanceof ClaudeProcess)) return false;
|
||||
if (process.consumeFailure(owner, turn)) return true;
|
||||
const trailing = messages.slice(assistantIndex + 1);
|
||||
const results = trailing.filter((message) => message.role === "toolResult");
|
||||
if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
process.continueTurn(turn, messages, results, signal);
|
||||
return true;
|
||||
}
|
||||
|
||||
function toClaudeEnv(): NodeJS.ProcessEnv {
|
||||
// Preserve native CLI auth/routing, including launch-environment credentials.
|
||||
// Claude Code chooses the account and billing method, not LearnHarness.
|
||||
return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
|
||||
}
|
||||
|
||||
async function startProcess(
|
||||
runClaude: RunClaude,
|
||||
turn: ClaudeTurn,
|
||||
model: Model<Api>,
|
||||
context: TranscriptContext,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
owner: symbol,
|
||||
configuration: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
|
||||
const input = toClaudeInput(context.messages);
|
||||
let cliProcess: ClaudeProcess | undefined;
|
||||
const bridge = await createToolBridge(input.tools, {
|
||||
onCall: () => cliProcess?.flushAdmissions(),
|
||||
onError: (error) => cliProcess?.fail(error.message),
|
||||
onCancel: (call) => cliProcess?.cancelCall(call),
|
||||
});
|
||||
let files: TurnFiles;
|
||||
try {
|
||||
files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig);
|
||||
} catch (error) {
|
||||
await bridge.close();
|
||||
throw error;
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
turn.fail(ABORT_MESSAGE, "aborted");
|
||||
await removeTurnResources(bridge, files);
|
||||
return;
|
||||
}
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | null;
|
||||
try {
|
||||
const args = toClaudeArgs({
|
||||
model: model.id,
|
||||
effort: toEffort(options?.reasoning),
|
||||
systemPromptFile: files.systemPromptFile,
|
||||
mcpConfigFile: files.mcpConfigFile,
|
||||
allowedTools: bridge.allowedTools,
|
||||
});
|
||||
child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
|
||||
} catch (error) {
|
||||
await removeTurnResources(bridge, files);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
turn.fail(ClaudeCodeError.CliNotFound, "error");
|
||||
await removeTurnResources(bridge, files);
|
||||
return;
|
||||
}
|
||||
|
||||
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages)));
|
||||
cliProcess.start(toStreamJsonLine(input.userContent), options?.signal);
|
||||
}
|
||||
|
||||
export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction<Api, SimpleStreamOptions> {
|
||||
const owner = Symbol("claude-code stream owner");
|
||||
return (model, context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const turn = new ClaudeTurn(stream, model);
|
||||
|
||||
const env = toClaudeEnv();
|
||||
// An existing child must not retain stale authentication/routing after env changes.
|
||||
const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env };
|
||||
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
|
||||
|
||||
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
|
||||
console.error("claude CLI turn failed:", error);
|
||||
turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());
|
||||
@@ -0,0 +1,234 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import * as http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import type { ImageContent, JsonObject, TextContent, Tool, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
type CallToolResult,
|
||||
type Tool as McpTool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export const BRIDGE_SERVER_NAME = "learnharness";
|
||||
|
||||
const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`;
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
const TOKEN_BYTES = 32;
|
||||
|
||||
export type PendingToolCall = { id: string; name: string; arguments: JsonObject };
|
||||
|
||||
type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void };
|
||||
|
||||
export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId";
|
||||
|
||||
export type ToolBridge = {
|
||||
mcpConfig: string;
|
||||
allowedTools: string[];
|
||||
waitForToolCalls(count: number): Promise<PendingToolCall[]>;
|
||||
resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void;
|
||||
getPendingToolCalls(): PendingToolCall[];
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type ToolCallWaiter = {
|
||||
count: number;
|
||||
resolve: (calls: PendingToolCall[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function toolNameFromMcp(name: string): string {
|
||||
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
|
||||
}
|
||||
|
||||
function toMcpContent(block: TextContent | ImageContent): CallToolResult["content"][number] {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return { type: "text", text: block.text };
|
||||
|
||||
case "image":
|
||||
return { type: "image", data: block.data, mimeType: block.mimeType };
|
||||
}
|
||||
}
|
||||
|
||||
function toCallToolResult(result: ToolResultMessage): CallToolResult {
|
||||
return { content: result.content.map(toMcpContent), isError: result.isError };
|
||||
}
|
||||
|
||||
function toMcpTool(tool: Tool): McpTool {
|
||||
return { name: tool.name, description: tool.description, inputSchema: tool.parameters as McpTool["inputSchema"] };
|
||||
}
|
||||
|
||||
class ToolCallQueue {
|
||||
private readonly results = new Map<PendingToolCall, (result: CallToolResult) => void>();
|
||||
private waiters: ToolCallWaiter[] = [];
|
||||
|
||||
add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise<CallToolResult> {
|
||||
const result = new Promise<CallToolResult>((resolve) => {
|
||||
const cancel = () => {
|
||||
const settle = this.results.get(call);
|
||||
if (!settle) return;
|
||||
settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true });
|
||||
onCancel?.(call);
|
||||
};
|
||||
this.results.set(call, (result) => {
|
||||
this.results.delete(call);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
resolve(result);
|
||||
});
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
if (signal.aborted) cancel();
|
||||
});
|
||||
|
||||
this.wakeWaiters();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
waitFor(count: number): Promise<PendingToolCall[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.waiters.push({ count, resolve, reject });
|
||||
this.wakeWaiters();
|
||||
});
|
||||
}
|
||||
|
||||
resolve(call: PendingToolCall, result: ToolResultMessage): void {
|
||||
this.results.get(call)?.(toCallToolResult(result));
|
||||
}
|
||||
|
||||
list(): PendingToolCall[] {
|
||||
return [...this.results.keys()];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const settle of this.results.values()) {
|
||||
settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true });
|
||||
}
|
||||
|
||||
for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed"));
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
private wakeWaiters(): void {
|
||||
const calls = this.list();
|
||||
|
||||
const ready = this.waiters.filter((waiter) => calls.length >= waiter.count);
|
||||
this.waiters = this.waiters.filter((waiter) => calls.length < waiter.count);
|
||||
|
||||
for (const waiter of ready) waiter.resolve(calls);
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry
|
||||
// JSON Schema that must reach the CLI unchanged.
|
||||
function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: BridgeCallbacks): Server {
|
||||
const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) }));
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
||||
const id = request.params._meta?.["claudecode/toolUseId"];
|
||||
if (typeof id !== "string" || !id.trim() || seenIds.has(id)) {
|
||||
const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR);
|
||||
callbacks?.onError(error);
|
||||
throw error;
|
||||
}
|
||||
if (!tools.some((tool) => tool.name === request.params.name)) {
|
||||
const error = new McpError(ErrorCode.InvalidParams, "claude CLI requested an undeclared harness tool");
|
||||
callbacks?.onError(error);
|
||||
throw error;
|
||||
}
|
||||
seenIds.add(id);
|
||||
const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel);
|
||||
callbacks?.onCall();
|
||||
return result;
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function isAuthorized(header: string | undefined, expected: Buffer): boolean {
|
||||
if (header === undefined) return false;
|
||||
|
||||
const actual = Buffer.from(header);
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function createHttpServer(transport: StreamableHTTPServerTransport, authorization: string): http.Server {
|
||||
const expected = Buffer.from(authorization);
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
if (!isAuthorized(req.headers.authorization, expected)) {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
|
||||
transport.handleRequest(req, res).catch((error: unknown) => {
|
||||
console.error("claude-code tool bridge request failed", error);
|
||||
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: http.Server): Promise<AddressInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
|
||||
server.listen(0, LOOPBACK_HOST, () => resolve(server.address() as AddressInfo));
|
||||
});
|
||||
}
|
||||
|
||||
function closeHttpServer(server: http.Server): Promise<void> {
|
||||
server.closeAllConnections();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toMcpConfig(port: number, authorization: string): string {
|
||||
const server = { type: "http", url: `http://${LOOPBACK_HOST}:${port}/mcp`, headers: { Authorization: authorization } };
|
||||
|
||||
return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } });
|
||||
}
|
||||
|
||||
export async function createToolBridge(tools: Tool[], callbacks?: BridgeCallbacks): Promise<ToolBridge> {
|
||||
const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`;
|
||||
|
||||
const queue = new ToolCallQueue();
|
||||
const mcpServer = createMcpServer(tools, queue, callbacks);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const httpServer = createHttpServer(transport, authorization);
|
||||
let port: number;
|
||||
try {
|
||||
({ port } = await listen(httpServer));
|
||||
} catch (error) {
|
||||
await mcpServer.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
mcpConfig: toMcpConfig(port, authorization),
|
||||
allowedTools: tools.map((tool) => `${MCP_TOOL_PREFIX}${tool.name}`),
|
||||
waitForToolCalls: (count) => queue.waitFor(count),
|
||||
resolveToolCall: (call, result) => queue.resolve(call, result),
|
||||
getPendingToolCalls: () => queue.list(),
|
||||
close: async () => {
|
||||
queue.clear();
|
||||
await mcpServer.close();
|
||||
await closeHttpServer(httpServer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai";
|
||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||
|
||||
export type ClaudeContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; source: { type: "base64"; media_type: string; data: string } };
|
||||
|
||||
export type ClaudeInput = {
|
||||
systemPrompt: string;
|
||||
tools: Tool[];
|
||||
userContent: ClaudeContentBlock[];
|
||||
};
|
||||
|
||||
type ReplayPart = string | ImageContent;
|
||||
|
||||
function toImageBlock(image: ImageContent): ClaudeContentBlock {
|
||||
return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
|
||||
}
|
||||
|
||||
function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] {
|
||||
if (typeof content === "string") return [`${label}\n${content}`];
|
||||
|
||||
const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
|
||||
const images = content.filter((block) => block.type === "image");
|
||||
|
||||
return [text ? `${label}\n${text}` : label, ...images];
|
||||
}
|
||||
|
||||
function getReplayParts(message: Message): ReplayPart[] {
|
||||
switch (message.role) {
|
||||
case "system":
|
||||
return [];
|
||||
|
||||
case "user":
|
||||
return getLabelledParts("[user]", message.content);
|
||||
|
||||
case "assistant":
|
||||
return message.content.flatMap((block) => {
|
||||
if (block.type === "text") return [`[assistant]\n${block.text}`];
|
||||
if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`];
|
||||
return [];
|
||||
});
|
||||
|
||||
case "toolResult": {
|
||||
const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`;
|
||||
return getLabelledParts(label, message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
|
||||
const blocks: ClaudeContentBlock[] = [];
|
||||
let pendingText: string[] = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") });
|
||||
pendingText = [];
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
pendingText.push(part);
|
||||
} else {
|
||||
flushText();
|
||||
blocks.push(toImageBlock(part));
|
||||
}
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] {
|
||||
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||
|
||||
return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block)));
|
||||
}
|
||||
|
||||
function toUserContent(conversation: Message[]): ClaudeContentBlock[] {
|
||||
const [first] = conversation;
|
||||
if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content);
|
||||
|
||||
return toContentBlocks(conversation.flatMap(getReplayParts));
|
||||
}
|
||||
|
||||
export function toClaudeInput(messages: Message[]): ClaudeInput {
|
||||
const conversation = messages.filter((message) => message.role !== "system");
|
||||
|
||||
return {
|
||||
systemPrompt: getCurrentSystemPrompt(messages),
|
||||
tools: getCurrentTools(messages),
|
||||
userContent: toUserContent(conversation),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
|
||||
return JSON.stringify({ type: "user", message: { role: "user", content } });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mario Zechner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
export function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null>;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-convert.js (MIT).
|
||||
// Only inline-image conversion is retained; terminal/Kitty registration is not used.
|
||||
import { applyExifOrientation } from "./exif-orientation.mjs";
|
||||
import { loadPhoton } from "./photon.mjs";
|
||||
|
||||
function encodePng(photon, bytes) {
|
||||
try {
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
|
||||
const image = applyExifOrientation(photon, rawImage, bytes);
|
||||
if (image !== rawImage) rawImage.free();
|
||||
try {
|
||||
return new Uint8Array(image.get_bytes());
|
||||
} finally {
|
||||
image.free();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertImageBytesToPng(bytes) {
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) return null;
|
||||
return encodePng(photon, bytes);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/exif-orientation.js (MIT).
|
||||
function readOrientationFromTiff(bytes, tiffStart) {
|
||||
if (tiffStart + 8 > bytes.length)
|
||||
return 1;
|
||||
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
|
||||
const le = byteOrder === 0x4949;
|
||||
const read16 = (pos) => {
|
||||
if (le)
|
||||
return bytes[pos] | (bytes[pos + 1] << 8);
|
||||
return (bytes[pos] << 8) | bytes[pos + 1];
|
||||
};
|
||||
const read32 = (pos) => {
|
||||
if (le)
|
||||
return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
|
||||
return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
|
||||
};
|
||||
const ifdOffset = read32(tiffStart + 4);
|
||||
const ifdStart = tiffStart + ifdOffset;
|
||||
if (ifdStart + 2 > bytes.length)
|
||||
return 1;
|
||||
const entryCount = read16(ifdStart);
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
const entryPos = ifdStart + 2 + i * 12;
|
||||
if (entryPos + 12 > bytes.length)
|
||||
return 1;
|
||||
if (read16(entryPos) === 0x0112) {
|
||||
const value = read16(entryPos + 8);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
function findJpegTiffOffset(bytes) {
|
||||
let offset = 2;
|
||||
while (offset < bytes.length - 1) {
|
||||
if (bytes[offset] !== 0xff)
|
||||
return -1;
|
||||
const marker = bytes[offset + 1];
|
||||
if (marker === 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xe1) {
|
||||
if (offset + 4 >= bytes.length)
|
||||
return -1;
|
||||
const segmentStart = offset + 4;
|
||||
if (segmentStart + 6 > bytes.length)
|
||||
return -1;
|
||||
if (hasExifHeader(bytes, segmentStart))
|
||||
return segmentStart + 6;
|
||||
}
|
||||
if (offset + 4 > bytes.length)
|
||||
return -1;
|
||||
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
offset += 2 + length;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function findWebpTiffOffset(bytes) {
|
||||
let offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
||||
const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
|
||||
const dataStart = offset + 8;
|
||||
if (chunkId === "EXIF") {
|
||||
if (dataStart + chunkSize > bytes.length)
|
||||
return -1;
|
||||
// Some WebP files have "Exif\0\0" prefix before the TIFF header
|
||||
const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
|
||||
return tiffStart;
|
||||
}
|
||||
// RIFF chunks are padded to even size
|
||||
offset = dataStart + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function hasExifHeader(bytes, offset) {
|
||||
return (bytes[offset] === 0x45 &&
|
||||
bytes[offset + 1] === 0x78 &&
|
||||
bytes[offset + 2] === 0x69 &&
|
||||
bytes[offset + 3] === 0x66 &&
|
||||
bytes[offset + 4] === 0x00 &&
|
||||
bytes[offset + 5] === 0x00);
|
||||
}
|
||||
function getExifOrientation(bytes) {
|
||||
let tiffOffset = -1;
|
||||
// JPEG: starts with FF D8
|
||||
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
tiffOffset = findJpegTiffOffset(bytes);
|
||||
}
|
||||
// WebP: starts with RIFF....WEBP
|
||||
else if (bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50) {
|
||||
tiffOffset = findWebpTiffOffset(bytes);
|
||||
}
|
||||
if (tiffOffset === -1)
|
||||
return 1;
|
||||
return readOrientationFromTiff(bytes, tiffOffset);
|
||||
}
|
||||
function rotate90(photon, image, dstIndex) {
|
||||
const w = image.get_width();
|
||||
const h = image.get_height();
|
||||
const src = image.get_raw_pixels();
|
||||
const dst = new Uint8Array(src.length);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const srcIdx = (y * w + x) * 4;
|
||||
const dstIdx = dstIndex(x, y, w, h) * 4;
|
||||
dst[dstIdx] = src[srcIdx];
|
||||
dst[dstIdx + 1] = src[srcIdx + 1];
|
||||
dst[dstIdx + 2] = src[srcIdx + 2];
|
||||
dst[dstIdx + 3] = src[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
return new photon.PhotonImage(dst, h, w);
|
||||
}
|
||||
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
|
||||
export function applyExifOrientation(photon, image, originalBytes) {
|
||||
const orientation = getExifOrientation(originalBytes);
|
||||
if (orientation === 1)
|
||||
return image;
|
||||
switch (orientation) {
|
||||
case 2:
|
||||
photon.fliph(image);
|
||||
return image;
|
||||
case 3:
|
||||
photon.fliph(image);
|
||||
photon.flipv(image);
|
||||
return image;
|
||||
case 4:
|
||||
photon.flipv(image);
|
||||
return image;
|
||||
case 5: {
|
||||
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
|
||||
photon.fliph(rotated);
|
||||
return rotated;
|
||||
}
|
||||
case 6:
|
||||
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
|
||||
case 7: {
|
||||
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
|
||||
photon.fliph(rotated);
|
||||
return rotated;
|
||||
}
|
||||
case 8:
|
||||
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
|
||||
default:
|
||||
return image;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-process.js (MIT).
|
||||
import { convertImageBytesToPng } from "./convert.mjs";
|
||||
import { formatDimensionNote, resizeImage } from "./resize.ts";
|
||||
import type { ProcessImageOptions, ProcessImageResult } from "./types.ts";
|
||||
|
||||
export { detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile } from "./mime.mjs";
|
||||
export type { ModelImageResizeOptions, ProcessImageOptions, ProcessImageResult, ResizedImage, SupportedImageMimeType } from "./types.ts";
|
||||
|
||||
function baseMimeType(mimeType: string): string {
|
||||
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeSupportedImageMimeType(mimeType: string): string | null {
|
||||
switch (baseMimeType(mimeType)) {
|
||||
case "image/png": return "image/png";
|
||||
case "image/jpeg":
|
||||
case "image/jpg": return "image/jpeg";
|
||||
case "image/gif": return "image/gif";
|
||||
case "image/webp": return "image/webp";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeImage(bytes: Uint8Array, mimeType: string): Promise<{ bytes: Uint8Array; mimeType: string; convertedFrom?: string } | null> {
|
||||
const normalizedMimeType = normalizeSupportedImageMimeType(mimeType);
|
||||
if (normalizedMimeType) return { bytes, mimeType: normalizedMimeType };
|
||||
const pngBytes = await convertImageBytesToPng(bytes);
|
||||
if (!pngBytes) return null;
|
||||
return { bytes: pngBytes, mimeType: "image/png", convertedFrom: baseMimeType(mimeType) };
|
||||
}
|
||||
|
||||
function conversionHint(from: string | undefined, to: string): string | undefined {
|
||||
if (!from || from === to) return undefined;
|
||||
return `[Image converted from ${from} to ${to}.]`;
|
||||
}
|
||||
|
||||
export async function processImage(bytes: Uint8Array, mimeType: string, options?: ProcessImageOptions): Promise<ProcessImageResult> {
|
||||
const autoResizeImages = options?.autoResizeImages ?? true;
|
||||
const normalized = await normalizeImage(bytes, mimeType);
|
||||
if (!normalized) {
|
||||
return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
|
||||
}
|
||||
if (autoResizeImages) {
|
||||
const resized = await resizeImage(normalized.bytes, normalized.mimeType, options?.resizeOptions);
|
||||
if (!resized) {
|
||||
return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
|
||||
}
|
||||
const hints: string[] = [];
|
||||
const convertedHint = conversionHint(normalized.convertedFrom, resized.mimeType);
|
||||
if (convertedHint) hints.push(convertedHint);
|
||||
const dimensionNote = formatDimensionNote(resized);
|
||||
if (dimensionNote) hints.push(dimensionNote);
|
||||
return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
|
||||
}
|
||||
const hints: string[] = [];
|
||||
const convertedHint = conversionHint(normalized.convertedFrom, normalized.mimeType);
|
||||
if (convertedHint) hints.push(convertedHint);
|
||||
return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SupportedImageMimeType } from "./types.ts";
|
||||
export function detectSupportedImageMimeType(buffer: Uint8Array): SupportedImageMimeType | null;
|
||||
export function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<SupportedImageMimeType | null>;
|
||||
@@ -0,0 +1,110 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/mime.js (MIT).
|
||||
import { open } from "node:fs/promises";
|
||||
const IMAGE_TYPE_SNIFF_BYTES = 4100;
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
export function detectSupportedImageMimeType(buffer) {
|
||||
if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
|
||||
return buffer[3] === 0xf7 ? null : "image/jpeg";
|
||||
}
|
||||
if (startsWith(buffer, PNG_SIGNATURE)) {
|
||||
return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
|
||||
}
|
||||
if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {
|
||||
return "image/webp";
|
||||
}
|
||||
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
|
||||
return "image/bmp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export async function detectSupportedImageMimeTypeFromFile(filePath) {
|
||||
const fileHandle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0);
|
||||
return detectSupportedImageMimeType(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
function isPng(buffer) {
|
||||
return (buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR"));
|
||||
}
|
||||
function isAnimatedPng(buffer) {
|
||||
let offset = PNG_SIGNATURE.length;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const chunkLength = readUint32BE(buffer, offset);
|
||||
const chunkTypeOffset = offset + 4;
|
||||
if (startsWithAscii(buffer, chunkTypeOffset, "acTL"))
|
||||
return true;
|
||||
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT"))
|
||||
return false;
|
||||
const nextOffset = offset + 8 + chunkLength + 4;
|
||||
if (nextOffset <= offset || nextOffset > buffer.length)
|
||||
return false;
|
||||
offset = nextOffset;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function isBmp(buffer) {
|
||||
if (buffer.length < 26)
|
||||
return false;
|
||||
const declaredFileSize = readUint32LE(buffer, 2);
|
||||
const pixelDataOffset = readUint32LE(buffer, 10);
|
||||
const dibHeaderSize = readUint32LE(buffer, 14);
|
||||
if (declaredFileSize !== 0 && declaredFileSize < 26)
|
||||
return false;
|
||||
if (pixelDataOffset < 14 + dibHeaderSize)
|
||||
return false;
|
||||
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize)
|
||||
return false;
|
||||
let colorPlanes;
|
||||
let bitsPerPixel;
|
||||
if (dibHeaderSize === 12) {
|
||||
colorPlanes = readUint16LE(buffer, 22);
|
||||
bitsPerPixel = readUint16LE(buffer, 24);
|
||||
}
|
||||
else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
|
||||
if (buffer.length < 30)
|
||||
return false;
|
||||
colorPlanes = readUint16LE(buffer, 26);
|
||||
bitsPerPixel = readUint16LE(buffer, 28);
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
|
||||
}
|
||||
function readUint16LE(buffer, offset) {
|
||||
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
|
||||
}
|
||||
function readUint32BE(buffer, offset) {
|
||||
return ((buffer[offset] ?? 0) * 0x1000000 +
|
||||
((buffer[offset + 1] ?? 0) << 16) +
|
||||
((buffer[offset + 2] ?? 0) << 8) +
|
||||
(buffer[offset + 3] ?? 0));
|
||||
}
|
||||
function readUint32LE(buffer, offset) {
|
||||
return ((buffer[offset] ?? 0) +
|
||||
((buffer[offset + 1] ?? 0) << 8) +
|
||||
((buffer[offset + 2] ?? 0) << 16) +
|
||||
(buffer[offset + 3] ?? 0) * 0x1000000);
|
||||
}
|
||||
function startsWith(buffer, bytes) {
|
||||
if (buffer.length < bytes.length)
|
||||
return false;
|
||||
return bytes.every((byte, index) => buffer[index] === byte);
|
||||
}
|
||||
function startsWithAscii(buffer, offset, text) {
|
||||
if (buffer.length < offset + text.length)
|
||||
return false;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (buffer[offset + index] !== text.charCodeAt(index))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Adapted from @earendil-works/pi-coding-agent 1.1.0, dist/utils/photon.js (MIT).
|
||||
// Source-vs-runtime adaptation: this app runs in Node/Electron, not a Bun
|
||||
// compiled executable. Photon loads its colocated WASM normally; no global fs
|
||||
// monkeypatch or executable-relative WASM fallback is needed.
|
||||
let photonModule = null;
|
||||
let loadPromise = null;
|
||||
|
||||
export async function loadPhoton() {
|
||||
if (photonModule) return photonModule;
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
photonModule = await import("@silvia-odwyer/photon-node");
|
||||
return photonModule;
|
||||
} catch {
|
||||
photonModule = null;
|
||||
return photonModule;
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
|
||||
export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE: "pi:image-resize-response";
|
||||
export function resizeImageInProcess(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null>;
|
||||
export type ResizeImageWorkerResponse = {
|
||||
type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
|
||||
result: ResizedImage | null;
|
||||
} | {
|
||||
type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
|
||||
error: string;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-core.js (MIT).
|
||||
// Runtime .mjs resources stay unbundled for Next's Node/Electron worker packaging.
|
||||
import { applyExifOrientation } from "./exif-orientation.mjs";
|
||||
import { loadPhoton } from "./photon.mjs";
|
||||
/**
|
||||
* Tag on image resize worker replies. Node can post its own messages on the worker channel
|
||||
* (for example `{ "watch:require": [...] }` under `node --watch`), so replies must be identifiable.
|
||||
*/
|
||||
export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE = "pi:image-resize-response";
|
||||
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
|
||||
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
|
||||
const DEFAULT_OPTIONS = {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
jpegQuality: 80,
|
||||
};
|
||||
function encodeCandidate(buffer, mimeType) {
|
||||
const data = Buffer.from(buffer).toString("base64");
|
||||
return {
|
||||
data,
|
||||
encodedSize: Buffer.byteLength(data, "utf-8"),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Resize an image to fit within the specified max dimensions and encoded file size.
|
||||
* Returns null if the image cannot be resized below maxBytes.
|
||||
*
|
||||
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
|
||||
* returns null.
|
||||
*
|
||||
* Strategy for staying under maxBytes:
|
||||
* 1. First resize to maxWidth/maxHeight
|
||||
* 2. Try PNG first, then JPEG qualities in insertion order (first under cap wins)
|
||||
* 3. If still too large, try the remaining JPEG qualities
|
||||
* 4. If still too large, progressively reduce dimensions until 1x1
|
||||
*/
|
||||
export async function resizeImageInProcess(inputBytes, mimeType, options) {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
return null;
|
||||
}
|
||||
let image;
|
||||
try {
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
|
||||
image = applyExifOrientation(photon, rawImage, inputBytes);
|
||||
if (image !== rawImage)
|
||||
rawImage.free();
|
||||
const originalWidth = image.get_width();
|
||||
const originalHeight = image.get_height();
|
||||
const format = mimeType.split("/")[1] ?? "png";
|
||||
// Check if already within all limits (dimensions AND encoded size)
|
||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
|
||||
return {
|
||||
data: Buffer.from(inputBytes).toString("base64"),
|
||||
mimeType: mimeType || `image/${format}`,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
wasResized: false,
|
||||
};
|
||||
}
|
||||
// Calculate initial dimensions respecting max limits
|
||||
let targetWidth = originalWidth;
|
||||
let targetHeight = originalHeight;
|
||||
if (targetWidth > opts.maxWidth) {
|
||||
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
||||
targetWidth = opts.maxWidth;
|
||||
}
|
||||
if (targetHeight > opts.maxHeight) {
|
||||
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
||||
targetHeight = opts.maxHeight;
|
||||
}
|
||||
function tryEncodings(width, height, jpegQualities) {
|
||||
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
|
||||
try {
|
||||
const candidates = [encodeCandidate(resized.get_bytes(), "image/png")];
|
||||
for (const quality of jpegQualities) {
|
||||
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
finally {
|
||||
resized.free();
|
||||
}
|
||||
}
|
||||
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
|
||||
let currentWidth = targetWidth;
|
||||
let currentHeight = targetHeight;
|
||||
while (true) {
|
||||
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.encodedSize < opts.maxBytes) {
|
||||
return {
|
||||
data: candidate.data,
|
||||
mimeType: candidate.mimeType,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
wasResized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (currentWidth === 1 && currentHeight === 1) {
|
||||
break;
|
||||
}
|
||||
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
|
||||
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
|
||||
if (nextWidth === currentWidth && nextHeight === currentHeight) {
|
||||
break;
|
||||
}
|
||||
currentWidth = nextWidth;
|
||||
currentHeight = nextHeight;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
if (image) {
|
||||
image.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-worker.js (MIT).
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import { IMAGE_RESIZE_WORKER_RESPONSE_TYPE, resizeImageInProcess, } from "./resize-core.mjs";
|
||||
function isResizeImageWorkerRequest(value) {
|
||||
if (!value || typeof value !== "object")
|
||||
return false;
|
||||
const record = value;
|
||||
return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
|
||||
}
|
||||
const port = parentPort;
|
||||
if (!port) {
|
||||
throw new Error("image resize worker requires parentPort");
|
||||
}
|
||||
port.once("message", (message) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isResizeImageWorkerRequest(message)) {
|
||||
throw new Error("Invalid image resize worker request");
|
||||
}
|
||||
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
|
||||
const response = { type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, result };
|
||||
port.postMessage(response);
|
||||
}
|
||||
catch (error) {
|
||||
const response = {
|
||||
type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
port.postMessage(response);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize.js (MIT).
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import {
|
||||
IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
|
||||
resizeImageInProcess,
|
||||
type ResizeImageWorkerResponse,
|
||||
} from "./resize-core.mjs";
|
||||
import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
|
||||
|
||||
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
|
||||
return value !== null && typeof value === "object" && "type" in value && value.type === IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
|
||||
}
|
||||
|
||||
async function resizeImageInWorker(workerPath: string, inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null> {
|
||||
const worker = new Worker(workerPath);
|
||||
try {
|
||||
// Transfer a worker-owned copy so the caller's bytes remain intact.
|
||||
const inputBytesForWorker = new Uint8Array(inputBytes);
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (result: ResizedImage | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
worker.on("message", (message: unknown) => {
|
||||
// Ignore Node's own worker-channel messages (e.g. node --watch).
|
||||
if (!isResizeImageWorkerResponse(message)) return;
|
||||
if ("error" in message) fail(new Error(message.error));
|
||||
else settle(message.result);
|
||||
});
|
||||
worker.once("error", fail);
|
||||
worker.once("exit", (code) => {
|
||||
if (!settled) fail(new Error(`Image resize worker exited with code ${code}`));
|
||||
});
|
||||
worker.postMessage({ inputBytes: inputBytesForWorker, mimeType, options }, [inputBytesForWorker.buffer]);
|
||||
});
|
||||
} finally {
|
||||
void worker.terminate().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resizeImage(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null> {
|
||||
// Source-vs-runtime adaptation: Next bundles the TS orchestration, but traces
|
||||
// unbundled .mjs worker/core/EXIF/Photon resources at this cwd-relative path.
|
||||
// The packaged server's cwd is the standalone root. No Bun path is needed.
|
||||
const workerPath = path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs");
|
||||
try {
|
||||
return await resizeImageInWorker(workerPath, inputBytes, mimeType, options);
|
||||
} catch {
|
||||
return resizeImageInProcess(inputBytes, mimeType, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDimensionNote(result: ResizedImage): string | undefined {
|
||||
if (!result.wasResized) return undefined;
|
||||
const scale = result.originalWidth / result.width;
|
||||
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Port baseline: @earendil-works/pi-coding-agent 1.1.0 image utilities (MIT).
|
||||
/** Native resize overrides, supplied by the caller for the selected model. */
|
||||
export interface ModelImageResizeOptions {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
maxBytes?: number;
|
||||
jpegQuality?: number;
|
||||
}
|
||||
|
||||
export interface ResizedImage {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
wasResized: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessImageOptions {
|
||||
autoResizeImages?: boolean;
|
||||
resizeOptions?: ModelImageResizeOptions;
|
||||
}
|
||||
|
||||
export type ProcessImageResult = {
|
||||
ok: true;
|
||||
data: string;
|
||||
mimeType: string;
|
||||
hints: string[];
|
||||
} | {
|
||||
ok: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SupportedImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/bmp";
|
||||
+62
-10
@@ -5,9 +5,11 @@ import {
|
||||
type Api,
|
||||
type Model,
|
||||
type Models,
|
||||
type MutableModels,
|
||||
type StreamFunction,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
||||
import { lazyApi, lazyStream } from "@earendil-works/pi-ai/api/lazy";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { type ModelRole } from "../../config.ts";
|
||||
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
|
||||
@@ -15,6 +17,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
|
||||
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||
import { createClaudeCodeProvider } from "./claude-code/provider.ts";
|
||||
import { streamClaudeCode } from "./claude-code/stream.ts";
|
||||
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
|
||||
import { nativeSubscriptionStream } from "./subscription-stream.ts";
|
||||
|
||||
@@ -41,14 +45,31 @@ export const APP_IDENTITY = Object.freeze({
|
||||
export interface PiEngine {
|
||||
model: Model<Api>;
|
||||
streamFn: StreamFunction;
|
||||
/** The resolved (config-or-settings, CLAMPED) thinking level —
|
||||
* observability + tests; the streamFn pins it anyway. */
|
||||
/** The initially resolved (config-or-settings, CLAMPED) thinking level.
|
||||
* Custom streams re-resolve the original preference per operation. */
|
||||
level?: ThinkingLevel;
|
||||
}
|
||||
|
||||
export type { ThinkingLevel };
|
||||
|
||||
let collection: ReturnType<typeof createModels> | undefined;
|
||||
interface ProviderSource {
|
||||
sync(models: MutableModels): void;
|
||||
isCustom(id: string): boolean;
|
||||
}
|
||||
let providerSource: ProviderSource | undefined;
|
||||
|
||||
/** Configuration IO is injected by the server; the agent never imports stores. */
|
||||
export function configureProviderSource(source: ProviderSource): void {
|
||||
// Server module re-evaluation can replace the source while this collection
|
||||
// survives. Retire only the previous source's routes, not built-ins.
|
||||
if (collection && providerSource) {
|
||||
for (const provider of collection.getProviders()) {
|
||||
if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id);
|
||||
}
|
||||
}
|
||||
providerSource = source;
|
||||
}
|
||||
|
||||
/** The process-wide pi-ai Models collection — EVERY builtin provider's
|
||||
* catalog (model slots wave, 2026-09-20): engines and the picker resolve
|
||||
@@ -61,8 +82,17 @@ let collection: ReturnType<typeof createModels> | undefined;
|
||||
export function piModels() {
|
||||
if (!collection) {
|
||||
collection = createModels({ identity: APP_IDENTITY });
|
||||
for (const provider of builtinProviders()) collection.setProvider(provider);
|
||||
for (const provider of builtinProviders()) {
|
||||
// Keep the native Bedrock implementation reachable by standalone tracing.
|
||||
// Pi's default Node-only loader hides this import from bundlers. Only
|
||||
// replace its lazy forwarding; preserve the native factory's auth/catalog.
|
||||
collection.setProvider(provider.id === "amazon-bedrock"
|
||||
? { ...provider, ...lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")) }
|
||||
: provider);
|
||||
}
|
||||
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
|
||||
}
|
||||
providerSource?.sync(collection);
|
||||
return collection;
|
||||
}
|
||||
|
||||
@@ -102,26 +132,48 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
||||
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
||||
const models: Models = piModels();
|
||||
const nativeId = nativeSubscriptionId(spec.provider);
|
||||
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
|
||||
const registeredModel = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
|
||||
const custom = providerSource?.isCustom(spec.provider) ?? false;
|
||||
// Custom endpoint owners authorize image attempts independently of sparse
|
||||
// discovery metadata. Never promote the registered/catalog capabilities.
|
||||
const permitImages = (value: Model<Api>): Model<Api> => ({ ...value, input: ["text", "image"] });
|
||||
const model = custom && registeredModel ? permitImages(registeredModel) : registeredModel;
|
||||
if (!model) {
|
||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
||||
}
|
||||
// UNSET level → the baked default order (first entry the model
|
||||
// supports); SET level → pi's native nearest-clamp (model swaps keep
|
||||
// the user's intent and adapt honestly).
|
||||
const level = spec.thinkingLevel
|
||||
? clampThinkingLevel(model, spec.thinkingLevel)
|
||||
const thinkingPreference = spec.thinkingLevel;
|
||||
const level = thinkingPreference
|
||||
? clampThinkingLevel(model, thinkingPreference)
|
||||
: defaultThinkingLevel(model);
|
||||
// A lesson's agent loop can stay alive through many tool calls. Remember
|
||||
// only that this is a custom route, never its configuration for that run.
|
||||
return {
|
||||
model,
|
||||
level,
|
||||
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
||||
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
||||
// the pin honest even when config or settings drift off the map.
|
||||
// The agent loop forwards state.thinkingLevel as `reasoning` (undefined
|
||||
// = off on pi-ai's scale). Built-ins keep the factory clamp; custom
|
||||
// requests re-resolve thinking against their current metadata below.
|
||||
streamFn: (requestModel, context, options) => {
|
||||
if (custom) {
|
||||
// Revalidate before EVERY upstream model operation, including later
|
||||
// tool-loop steps. Resolve thinking from the ORIGINAL preference (or
|
||||
// existing default order), not a previous metadata snapshot's clamp.
|
||||
// Pi retains the setup of an operation already in flight.
|
||||
const current = piModels();
|
||||
const currentModel = current.getModel(spec.provider, spec.id);
|
||||
if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); });
|
||||
const currentLevel = thinkingPreference
|
||||
? clampThinkingLevel(currentModel, thinkingPreference)
|
||||
: defaultThinkingLevel(currentModel);
|
||||
return current.streamSimple(permitImages(currentModel), context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
|
||||
}
|
||||
const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
|
||||
const purpose = typeof background === "function" ? background : () => background;
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
|
||||
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||
return stream(requestModel, context, requestOptions);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void {
|
||||
* the liveness gate for the stale-job sweep: a "running" record with a
|
||||
* live registration belongs to a loop this process owns (hung or slow, it
|
||||
* is the one truth — never re-spawn beside it); without one the record is
|
||||
* an orphan of a process death and recovers immediately. The span is
|
||||
* loop-start to delivery-completion: the runner's unregistering finally
|
||||
* runs only after deliver() resolves, by which point finalizeJob has
|
||||
* flipped the record — so absence of registration is unambiguous. */
|
||||
* an orphan of a process death and recovers immediately. The registration
|
||||
* spans computation and cleanup only. Finished outcomes have independent,
|
||||
* persisted delivery obligations; no worker waits for their consumption. */
|
||||
export function isJobLive(jobId: string): boolean {
|
||||
return registry().has(jobId);
|
||||
}
|
||||
|
||||
/** Abort a running job's loop. Returns false when no controller is live —
|
||||
* the job's loop is not executing (crashed, or in the delivering-result
|
||||
* phase where the runner has already returned); the caller then owns the
|
||||
* whole abort on the record side. */
|
||||
* the job's loop is not executing. The endpoint separately refuses abort
|
||||
* for a finished worker, even while its session delivery remains pending. */
|
||||
export function fireJobAbort(jobId: string): boolean {
|
||||
const controller = registry().get(jobId);
|
||||
if (!controller) return false;
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { GraphStore } from "../../core/ports.ts";
|
||||
import { emit } from "../../core/events.ts";
|
||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
|
||||
import { auditorGraphTools } from "../tools/graph.ts";
|
||||
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||
|
||||
@@ -125,16 +125,20 @@ export function transcriptView(record: ChatRecord): string {
|
||||
}
|
||||
|
||||
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
|
||||
* ONLY the teacher agent's text blocks — no learner messages, no
|
||||
* thinking, no tool calls or results, no quiz blocks — each labeled with
|
||||
* ONLY learner-facing teacher prose (text or send_to_user) — no learner
|
||||
* messages, thinking, other tool calls/results, or quiz blocks — labeled with
|
||||
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
|
||||
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
|
||||
* lines the coverage fingerprints hash, so the extractor can anchor
|
||||
* every proposition to the passage it came from (the session submit's
|
||||
* required messageId). The shared markdown engine stays reserved for the
|
||||
* future user-facing export. */
|
||||
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
|
||||
const lines = messages.flatMap((message) => messageViewLines(message, message.id));
|
||||
function proseView(
|
||||
messages: ReadonlyArray<LhMessage>,
|
||||
title?: string,
|
||||
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds(messages),
|
||||
): string {
|
||||
const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
|
||||
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
|
||||
return head + lines.join("\n\n");
|
||||
}
|
||||
@@ -1119,6 +1123,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// stage runs (typed — the trigger skips, routes surface 503).
|
||||
refuseIfStagesUnready();
|
||||
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
||||
const failedOutputCalls = failedOutputCallIds(record.messages);
|
||||
if (covered.uncovered.length === 0) {
|
||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||
return { nodes: 0, chains: 0 };
|
||||
@@ -1154,14 +1159,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// fingerprints' messageViewLines ("TEACHER [id] text"), one
|
||||
// shared seam.
|
||||
const messages = batch.map((slot) => slot.message);
|
||||
const view = proseView(messages, record.title);
|
||||
const view = proseView(messages, record.title, failedOutputCalls);
|
||||
// The batch's teacher-prose ids — the ONLY anchors this
|
||||
// job's submit may name (validateSessionAnchors at the
|
||||
// submit seam refuses anything else: ids from other
|
||||
// batches — even real ones of this chat — other chats,
|
||||
// and non-prose messages).
|
||||
const batchIds = new Set(
|
||||
messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
|
||||
messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
|
||||
);
|
||||
setPhase(jobKey, "queued", "chat", undefined, part);
|
||||
const findings = await withExtractSlot(jobKey, async () => {
|
||||
@@ -1194,7 +1199,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
|
||||
foldedAt,
|
||||
coverage: batch
|
||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||
.filter((slot) => slot.tokens > 0)
|
||||
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||
});
|
||||
// The layout ledger evolves with the merge (sync — the
|
||||
|
||||
@@ -461,6 +461,54 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buff
|
||||
|
||||
// ─── PDF page rasterization (pdfjs paints, Skia hosts) ─────────────────
|
||||
|
||||
/** Decimal megapixels: at four bytes per pixel this is about 61 MiB per
|
||||
* page canvas, before pdfjs/Skia's other working memory. */
|
||||
export const MAX_PDF_CANVAS_PIXELS = 16_000_000;
|
||||
export const MAX_PDF_CANVAS_SIDE = 8192;
|
||||
|
||||
/** Plan the allocation from the scale-1 viewport (including PDF rotation).
|
||||
* Keep ordinary DPI/ceil arithmetic unchanged; oversized pages lose scale,
|
||||
* never gain it. Exported as a pure seam so extreme geometry needs no canvas. */
|
||||
export function pdfCanvasGeometry(baseWidth: number, baseHeight: number, dpi: number): {
|
||||
scale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (![baseWidth, baseHeight].every((n) => Number.isFinite(n) && n > 0)) {
|
||||
throw new Error("PDF page dimensions must be finite and positive");
|
||||
}
|
||||
if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
|
||||
throw new Error("PDF render DPI must be finite and positive");
|
||||
}
|
||||
// Limit sides first, so the area calculation cannot overflow even when
|
||||
// the unscaled page dimensions or requested DPI are enormous.
|
||||
let scale = Math.min(dpi / 72, MAX_PDF_CANVAS_SIDE / baseWidth, MAX_PDF_CANVAS_SIDE / baseHeight);
|
||||
const area = (baseWidth * scale) * (baseHeight * scale);
|
||||
if (area > MAX_PDF_CANVAS_PIXELS) scale *= Math.sqrt(MAX_PDF_CANVAS_PIXELS / area);
|
||||
const dimensions = (s: number) => ({
|
||||
width: Math.max(1, Math.ceil(baseWidth * s)),
|
||||
height: Math.max(1, Math.ceil(baseHeight * s)),
|
||||
});
|
||||
const fits = ({ width, height }: { width: number; height: number }) =>
|
||||
width <= MAX_PDF_CANVAS_SIDE && height <= MAX_PDF_CANVAS_SIDE && width * height <= MAX_PDF_CANVAS_PIXELS;
|
||||
// Ceil can put an otherwise bounded floating-point viewport over budget.
|
||||
// Find a safe scale rather than clipping the viewport or allocating first.
|
||||
if (!fits(dimensions(scale))) {
|
||||
let low = 0;
|
||||
let high = scale;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const mid = low + (high - low) / 2;
|
||||
if (fits(dimensions(mid))) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
scale = low;
|
||||
}
|
||||
if (scale <= 0 || baseWidth * scale === 0 || baseHeight * scale === 0) {
|
||||
throw new Error("PDF page dimensions cannot be rendered at a positive scale");
|
||||
}
|
||||
return { scale, ...dimensions(scale) };
|
||||
}
|
||||
|
||||
/** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes.
|
||||
*
|
||||
* History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's
|
||||
@@ -475,6 +523,9 @@ async function renderPdfPages(
|
||||
opts: RenderPdfOptions = {},
|
||||
): Promise<RenderedPdfPage[]> {
|
||||
const dpi = opts.dpi ?? 150;
|
||||
if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
|
||||
throw new Error("PDF render DPI must be finite and positive");
|
||||
}
|
||||
// NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into
|
||||
// @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's
|
||||
// own Node integration IS @napi-rs/canvas (its optionalDependency: the
|
||||
@@ -495,8 +546,10 @@ async function renderPdfPages(
|
||||
for (const pageNo of pages) {
|
||||
if (pageNo < 1 || pageNo > doc.numPages) continue;
|
||||
const pg = await doc.getPage(pageNo);
|
||||
const vp = pg.getViewport({ scale: dpi / 72 });
|
||||
const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
|
||||
const base = pg.getViewport({ scale: 1 });
|
||||
const geometry = pdfCanvasGeometry(base.width, base.height, dpi);
|
||||
const vp = pg.getViewport({ scale: geometry.scale });
|
||||
const canvas = createCanvas(geometry.width, geometry.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
// pdfjs's render types speak the DOM context; @napi-rs/canvas's
|
||||
// context is the same 2D API implemented over Skia.
|
||||
|
||||
@@ -2,7 +2,9 @@ import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||
import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
|
||||
import { Type } from "typebox";
|
||||
import { resolveSourcePath } from "../tools/fs-shared.ts";
|
||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
||||
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||
@@ -168,7 +170,7 @@ export async function runPdfReaderAgent(
|
||||
): Promise<PdfResult> {
|
||||
const headless = opts.headless ?? getHeadless();
|
||||
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||
const pdfPath = input.path;
|
||||
const pdfPath = resolveSourcePath(input.path);
|
||||
|
||||
// Deterministic upfront failure: unreadable / unparseable
|
||||
// for renders — the brief never starts on a broken source.
|
||||
@@ -247,7 +249,7 @@ export async function runPdfReaderAgent(
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
/** Rendered pages this batch, appended as images AFTER the tool
|
||||
* results — the visual-maker's image-in-the-loop idiom. */
|
||||
let renderedImages: { page: number; png: Buffer }[] | null = null;
|
||||
let renderedImages: { page: number; image: ProcessImageResult }[] | null = null;
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const args = (call.arguments ?? {}) as Record<string, unknown>;
|
||||
@@ -288,19 +290,26 @@ export async function runPdfReaderAgent(
|
||||
const wanted = specNumbers(spec, RENDER_BATCH_CAP);
|
||||
opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`);
|
||||
const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi });
|
||||
const processed: { page: number; image: ProcessImageResult }[] = [];
|
||||
for (const p of pages) {
|
||||
consulted.add(p.page);
|
||||
read.add(p.page);
|
||||
const image = await processImage(p.png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
|
||||
opts.abortSignal?.throwIfAborted();
|
||||
processed.push({ page: p.page, image });
|
||||
// Only images actually delivered to the model count as read.
|
||||
if (image.ok) {
|
||||
consulted.add(p.page);
|
||||
read.add(p.page);
|
||||
}
|
||||
}
|
||||
if (pages.length === 0) {
|
||||
toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)"));
|
||||
} else {
|
||||
renderedImages = pages;
|
||||
renderedImages = [...(renderedImages ?? []), ...processed];
|
||||
toolResults.push(
|
||||
toolText(
|
||||
call.id,
|
||||
call.name,
|
||||
`Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`,
|
||||
`Rendered ${pages.length} page(s) at ${dpi} DPI — processed images or explicit omission notes follow below. Read the images carefully (equations, diagrams, layout).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -324,11 +333,12 @@ export async function runPdfReaderAgent(
|
||||
type: "text",
|
||||
text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`,
|
||||
},
|
||||
...renderedImages.map((p) => ({
|
||||
type: "image" as const,
|
||||
data: p.png.toString("base64"),
|
||||
mimeType: "image/png",
|
||||
})),
|
||||
...renderedImages.flatMap(({ page, image }) => image.ok
|
||||
? [
|
||||
{ type: "text" as const, text: [`Page ${page}`, ...image.hints].join("\n") },
|
||||
{ type: "image" as const, data: image.data, mimeType: image.mimeType },
|
||||
]
|
||||
: [{ type: "text" as const, text: `Page ${page}: ${image.message}` }]),
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SearchProvider } from "../../core/search.ts";
|
||||
import {
|
||||
accumulateJobUsage,
|
||||
buildSubagentSteer,
|
||||
isSubagentFailure,
|
||||
piUsageToStep,
|
||||
type DemoResult,
|
||||
type DemoToolInput,
|
||||
@@ -41,13 +42,13 @@ import { runVisualMaker } from "./visual-maker.ts";
|
||||
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
|
||||
* updating the job's activity line on every iteration. On completion the
|
||||
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
|
||||
* `deliver`; the wake pipeline fires the server-side teacher turn and
|
||||
* flips the job to done/error when the lesson has actually resumed.
|
||||
* `deliver` without waiting. The worker's final outcome and pending
|
||||
* delivery are saved together; the session owns delivery from then on.
|
||||
*
|
||||
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
|
||||
* notes; research: failure brief) — and it STILL steers, because the
|
||||
* teacher must learn the outcome. If delivery itself fails, the job is
|
||||
* marked error (the learner's activity card shows it).
|
||||
* teacher must learn the outcome. Failed delivery starts stay quietly
|
||||
* pending and never rewrite the worker's computation outcome.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -69,9 +70,9 @@ export interface JobRunnerDeps {
|
||||
chatStore: ChatStore;
|
||||
jobStore: JobStore;
|
||||
/**
|
||||
* Hand a finished job's steer to the wake pipeline — resolves when the
|
||||
* steer is delivered (the wake turn completed and the job status
|
||||
* flipped); rejects when delivery fails unrecoverably.
|
||||
* Hand a finished job's steer to the session delivery registry. The
|
||||
* promise tracks durable marker acceptance, but the worker never waits
|
||||
* on it. Its persisted pending obligation survives process restart.
|
||||
*/
|
||||
deliver: (steer: SteerMessage) => Promise<void>;
|
||||
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
||||
@@ -98,8 +99,8 @@ export interface JobRunnerDeps {
|
||||
export interface JobRunner {
|
||||
/** Run one job's sub-agent loop to completion (background; never throws).
|
||||
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
||||
* before the tool part is persisted — only crash-recovery re-spawns
|
||||
* resolve the input from the transcript). */
|
||||
* before the tool part is persisted). Recovery reads saved job input,
|
||||
* with transcript fallback for legacy jobs. Delivery is not awaited. */
|
||||
runJob(jobId: string, input?: JobToolInput): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -272,14 +273,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the job's tool input: dispatch passes it directly; only a
|
||||
* crash-recovery re-spawn (no input) falls back to scanning the
|
||||
* transcript (by then onFinish has long persisted the part). */
|
||||
/** Dispatch input wins, then the job's saved arguments. Legacy jobs
|
||||
* without saved input fall back to the durable transcript, which may
|
||||
* lack a call if the dispatching turn died before settlement. */
|
||||
function resolveInput(
|
||||
job: JobRecord,
|
||||
input: JobToolInput | undefined,
|
||||
): JobToolInput | undefined {
|
||||
if (input) return input;
|
||||
if (job.input !== undefined) return job.input as JobToolInput;
|
||||
const chat = chatStore.getChat(job.chatId);
|
||||
if (!chat) return undefined;
|
||||
// Wave D: the record is native history — the scan reads the native
|
||||
@@ -297,27 +299,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail of every job: store the result, build the steer, hand it to
|
||||
* the wake pipeline. The pipeline flips the status when the lesson has
|
||||
* actually resumed — until then the job reads "running" so the client's
|
||||
* activity card keeps polling and re-syncs once, at the end.
|
||||
* Save the computation outcome and session delivery obligation together.
|
||||
* Worker liveness ends after cleanup, not after a lesson consumes the result.
|
||||
*/
|
||||
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (!job) return;
|
||||
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
||||
// Aborted mid-run (abortJob flips the record before
|
||||
// the loop unwinds): no result write, no steer — the abort path owns
|
||||
// the record and queues the abort steer itself.
|
||||
if (job.status !== "running") return;
|
||||
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
||||
const steer = buildSubagentSteer(job, result);
|
||||
try {
|
||||
await deliver(steer);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[job ${jobId}] steer delivery failed:`, err);
|
||||
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
|
||||
}
|
||||
const failed = isSubagentFailure(job.kind, result);
|
||||
jobStore.updateJob(jobId, {
|
||||
result,
|
||||
status: failed ? "error" : "done",
|
||||
activity: failed ? "failed" : "finished",
|
||||
delivery: { kind: "result", status: "pending" },
|
||||
});
|
||||
// The saved result now belongs to the session. Do not keep the worker,
|
||||
// abort registration, or temporary resources alive behind a question.
|
||||
void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => {
|
||||
console.error(`[job ${jobId}] pending delivery:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Chat-record + input resolution shared by all three runners. */
|
||||
@@ -345,7 +348,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
const { job } = ctx;
|
||||
|
||||
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
|
||||
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
|
||||
if (!(typeof input?.question === "string" && input.question.trim())) {
|
||||
jobStore.updateJob(jobId, { error: "research question unavailable" });
|
||||
await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] });
|
||||
return;
|
||||
}
|
||||
const question = input.question;
|
||||
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
|
||||
|
||||
const prompt = [
|
||||
|
||||
@@ -2,6 +2,7 @@ import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||
import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
|
||||
import { Type } from "typebox";
|
||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
||||
@@ -195,6 +196,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { svg: "", notes: `FAILED: rendering the final SVG failed — ${msg}` };
|
||||
}
|
||||
const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
|
||||
opts.abortSignal?.throwIfAborted();
|
||||
if (!image.ok) return { svg: "", notes: `FAILED: inspecting the final SVG failed — ${image.message}` };
|
||||
opts.onActivity?.("render_svg: inspecting final render");
|
||||
renderedSvg = currentSvg;
|
||||
messages = [
|
||||
@@ -202,8 +206,8 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: UNLOOKED_FINISH_MESSAGE },
|
||||
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
|
||||
{ type: "text", text: [UNLOOKED_FINISH_MESSAGE, ...image.hints].join("\n") },
|
||||
{ type: "image", data: image.data, mimeType: image.mimeType },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
@@ -214,7 +218,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
}
|
||||
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results
|
||||
let renderImage: Extract<ProcessImageResult, { ok: true }> | null = null; // appended AFTER the tool results
|
||||
for (const call of toolCalls) {
|
||||
if (call.name === "write_svg") {
|
||||
const svg = (call.arguments as { svg?: unknown }).svg;
|
||||
@@ -262,7 +266,10 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
}
|
||||
try {
|
||||
const png = await headless.renderSvg(currentSvg);
|
||||
renderPng = png;
|
||||
const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
|
||||
opts.abortSignal?.throwIfAborted();
|
||||
if (!image.ok) throw new Error(image.message);
|
||||
renderImage = image;
|
||||
renderedSvg = currentSvg;
|
||||
opts.onActivity?.("render_svg: inspecting render");
|
||||
toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it."));
|
||||
@@ -277,14 +284,14 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
}
|
||||
// Message order: assistant tool-calls → tool results → render image.
|
||||
messages = [...messages, ...toolResults];
|
||||
if (renderPng) {
|
||||
if (renderImage) {
|
||||
messages = [
|
||||
...messages,
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: LOOK_MESSAGE },
|
||||
{ type: "image", data: renderPng.toString("base64"), mimeType: "image/png" },
|
||||
{ type: "text", text: [LOOK_MESSAGE, ...renderImage.hints].join("\n") },
|
||||
{ type: "image", data: renderImage.data, mimeType: renderImage.mimeType },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
|
||||
function desktopConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
|
||||
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
|
||||
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
|
||||
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
|
||||
@@ -60,7 +62,11 @@ function bridge(): State {
|
||||
if (message.ok) pending.resolve(message.value);
|
||||
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
|
||||
});
|
||||
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
|
||||
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
|
||||
});
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
invalidate(state);
|
||||
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
|
||||
state.pending.clear(); notify(state);
|
||||
@@ -70,6 +76,7 @@ function bridge(): State {
|
||||
}
|
||||
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
|
||||
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
|
||||
}
|
||||
export async function refreshSubscriptionModels(): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopConnected()) { invalidate(state); return; }
|
||||
if (!runtimeConnected()) { invalidate(state); return; }
|
||||
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
|
||||
catch { invalidate(state); notify(state); }
|
||||
}
|
||||
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
|
||||
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
|
||||
const status = nativeSubscriptionStatus(providerId);
|
||||
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
}
|
||||
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
|
||||
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
|
||||
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Server-injected private web transport. The agent layer never imports a
|
||||
* filesystem owner or server composition root. Desktop IPC remains separate. */
|
||||
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
|
||||
export type SubscriptionRuntimePacket = Record<string, unknown>;
|
||||
interface Runtime {
|
||||
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
|
||||
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
|
||||
}
|
||||
const KEY = Symbol.for("lh.webSubscriptionRuntime");
|
||||
function runtime(): Runtime {
|
||||
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
|
||||
return global[KEY] ??= { listeners: new Map() };
|
||||
}
|
||||
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
|
||||
export function webSubscriptionsEnabled(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
|
||||
}
|
||||
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
|
||||
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
|
||||
listeners.add(listener); state.listeners.set(channel, listeners);
|
||||
}
|
||||
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
|
||||
for (const listener of runtime().listeners.get(channel) ?? []) {
|
||||
try { listener(packet); } catch { /* observer isolation */ }
|
||||
}
|
||||
}
|
||||
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
|
||||
return runtime().request!<T>(channel, op, fields);
|
||||
}
|
||||
+30
-12
@@ -16,10 +16,13 @@ import {
|
||||
type TeacherToolContext,
|
||||
} from "./tools.ts";
|
||||
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
||||
import { healToolRecord } from "./tools/heal.ts";
|
||||
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
||||
import { sendToUserTool } from "./tools/send-to-user.ts";
|
||||
import { createReadTool, readTool } from "./tools/read.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./claude-code/constants.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
||||
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
|
||||
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
||||
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
|
||||
|
||||
@@ -147,22 +150,27 @@ export interface AgentBuildOptions {
|
||||
* belt-and-suspenders HALF for the mixed batch (a question + other calls
|
||||
* in one message) that terminate-only semantics would let run on — the
|
||||
* learner cannot answer while a turn is in flight (per-chat 409), so a
|
||||
* cleanly-posed question MUST always end the turn. maxTurns is gone
|
||||
* cleanly-posed question MUST always end the turn. Practice's problem pose
|
||||
* has the same wait-for-the-learner contract, including mixed output/problem
|
||||
* batches; failed poses must still retry. maxTurns is gone
|
||||
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
|
||||
* turn).
|
||||
*/
|
||||
function makeQuestionStopper() {
|
||||
function makeQuestionStopper(kind: AgentKind) {
|
||||
return (context: {
|
||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||
}): boolean => {
|
||||
const called = new Set(
|
||||
context.message.content
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (
|
||||
(QUESTION_TOOLS as readonly string[]).includes(c.name) ||
|
||||
(kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
|
||||
))
|
||||
.map((c) => c.id!),
|
||||
);
|
||||
if (called.size === 0) return false;
|
||||
// Stop only when every question call of this turn resolved cleanly.
|
||||
// Stop only when every question/problem pose resolved cleanly.
|
||||
for (const result of context.toolResults) {
|
||||
if (called.has(result.toolCallId) && result.isError) return false;
|
||||
}
|
||||
@@ -176,7 +184,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
// conversation-spec resolution as teaching); the cast keeps AgentKind
|
||||
// ("practice" is not a ModelRole) honest about that.
|
||||
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
|
||||
const stopper = makeQuestionStopper();
|
||||
const stopper = makeQuestionStopper(kind);
|
||||
const basePrompt = opts.promptSwitches
|
||||
? composeInstructions(kind, opts.promptSwitches)
|
||||
: composedInstructions[kind]!;
|
||||
@@ -193,7 +201,18 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
systemPrompt,
|
||||
model: engine.model,
|
||||
thinkingLevel: engine.level,
|
||||
tools,
|
||||
// Conditional registry inclusion, like the graph-tool gates: when
|
||||
// closed, neither the tool nor its usage instructions reach the model.
|
||||
// Match Claude across providers (including OpenRouter), not the API
|
||||
// transport. Explicit test registries remain caller-owned.
|
||||
tools: [
|
||||
...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
|
||||
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
|
||||
: tool),
|
||||
...(opts.tools === undefined && (engine.model.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude"))
|
||||
? [withArgumentHeal(sendToUserTool)]
|
||||
: []),
|
||||
],
|
||||
messages: opts.messages ?? [],
|
||||
},
|
||||
streamFn: engine.streamFn,
|
||||
@@ -315,10 +334,9 @@ export function bareQuestionTools(): Record<string, AgentTool<any>> {
|
||||
* from turn 1 of a kind:"practice" session — no prober→teacher split,
|
||||
* no plan tools, no question tools. Its ENTIRE registry is
|
||||
* problem + conclude (tools/practice.ts); the doctrine prompt
|
||||
* (prompts/practice/) carries the method. The question-stop rule does
|
||||
* not apply (problem rides terminate: true natively —
|
||||
* QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
|
||||
* a no-op here; it stays wired for uniformity.
|
||||
* (prompts/practice/) carries the method. A successful problem pose ends
|
||||
* the run, natively for a problem-only batch and via the shared stop hook
|
||||
* for a mixed batch (for example, send_to_user + problem).
|
||||
*/
|
||||
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
|
||||
const tools = opts.tools ?? practiceTools(ctx);
|
||||
|
||||
@@ -78,7 +78,7 @@ function dispatch(
|
||||
input: ResearchInput | DemoInput | VisualInput | PdfInput,
|
||||
): string {
|
||||
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input });
|
||||
if (!existing) ctx.spawn(job, input);
|
||||
return `Dispatched (job ${job.id}). ${ack}`;
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
|
||||
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
|
||||
* echo. details = {ok:true} only.
|
||||
*
|
||||
* The first probe_done in the transcript is also the construction seam:
|
||||
* from that message on, every request builds the TEACHER agent.
|
||||
* The first concluded probe_done (non-failed call plus matching successful
|
||||
* result) is also the construction seam: from that result on, every request
|
||||
* builds the TEACHER agent. Bare calls and refused results keep probing.
|
||||
*/
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
|
||||
+38
-35
@@ -1,9 +1,10 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { Type } from "typebox";
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
|
||||
import { resolveSourcePath } from "./fs-shared.ts";
|
||||
import { detectSupportedImageMimeTypeFromFile, processImage } from "../image-processing/index.ts";
|
||||
|
||||
/**
|
||||
* The read tool — sources v0 (2026-09): a Node port of pi's read semantics
|
||||
@@ -31,17 +32,6 @@ import { resolveSourcePath } from "./fs-shared.ts";
|
||||
const MAX_LINES = 2000;
|
||||
const MAX_BYTES = 50 * 1024;
|
||||
|
||||
/** Extension map for v0 (pi sniffs magic bytes; extensions suffice here —
|
||||
* learner files are pasted paths, not adversarial uploads). */
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
const readInput = Type.Object({
|
||||
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
|
||||
@@ -95,26 +85,39 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
|
||||
* fragment (prompts/shared/). Relative paths resolve against the
|
||||
* learner's home (fs-shared.ts), the harness's anchor.
|
||||
*/
|
||||
export const readTool: AgentTool<typeof readInput> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
parameters: readInput,
|
||||
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => {
|
||||
const absolute = resolveSourcePath(rawPath);
|
||||
const ext = path.extname(absolute).toLowerCase();
|
||||
const mime = IMAGE_MIME[ext];
|
||||
const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees
|
||||
if (mime) {
|
||||
// NOTE v0: no resize — raw bytes as base64. Learner files are study
|
||||
// material (screenshots, figures), not adversarial payloads; a resize
|
||||
// step (sharp) can land later if large images prove wasteful.
|
||||
return {
|
||||
text: `Read image file [${mime}] — ${path.basename(absolute)} (${formatSize(buffer.byteLength)}), attached as an image.`,
|
||||
images: [{ data: buffer.toString("base64"), mimeType: mime }],
|
||||
};
|
||||
}
|
||||
const text = buffer.toString("utf-8");
|
||||
return readTextSlice(text, offset, limit);
|
||||
}),
|
||||
};
|
||||
export type ReadModel = Pick<Model<Api>, "input" | "inputLimits">;
|
||||
|
||||
/** One implementation for every learner-file reader. Bind the active model
|
||||
* per agent, rather than sharing mutable model state between conversations.
|
||||
* Home-relative paths, text slicing and the app's abort guard stay unchanged;
|
||||
* image detection/processing and its notes follow native Pi read behavior. */
|
||||
export function createReadTool(model?: ReadModel): AgentTool<typeof readInput> {
|
||||
return {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
parameters: readInput,
|
||||
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }, { signal }): Promise<string | ImageFileResult> => {
|
||||
const absolute = resolveSourcePath(rawPath);
|
||||
const mime = await detectSupportedImageMimeTypeFromFile(absolute);
|
||||
const buffer = await fs.readFile(absolute, { signal });
|
||||
if (mime) {
|
||||
const processed = await processImage(buffer, mime, { resizeOptions: model?.inputLimits?.images?.resize });
|
||||
signal?.throwIfAborted();
|
||||
const note = processed.ok
|
||||
? [`Read image file [${processed.mimeType}]`, ...processed.hints]
|
||||
: [`Read image file [${mime}]`, processed.message];
|
||||
if (model && !model.input.includes("image")) {
|
||||
note.push("[Current model does not support images. The image will be omitted from this request.]");
|
||||
}
|
||||
return {
|
||||
text: note.join("\n"),
|
||||
...(processed.ok ? { images: [{ data: processed.data, mimeType: processed.mimeType }] } : {}),
|
||||
};
|
||||
}
|
||||
return readTextSlice(buffer.toString("utf-8"), offset, limit);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const readTool = createReadTool();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
|
||||
const sendToUserInput = Type.Object({
|
||||
message: Type.String({
|
||||
minLength: 1,
|
||||
pattern: "\\S",
|
||||
description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Claude's documented verbatim mid-turn output channel. Availability is
|
||||
* model-gated at the shared conversation-agent construction boundary;
|
||||
* the native call remains in history, and the view renders its message. */
|
||||
export const sendToUserTool: AgentTool<typeof sendToUserInput> = {
|
||||
name: SEND_TO_USER_TOOL,
|
||||
label: SEND_TO_USER_TOOL,
|
||||
description:
|
||||
"## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
|
||||
"**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
|
||||
"feedback, corrections, hints, introductions, progress messages, and final responses. " +
|
||||
"There is no exception for short messages or the end of a turn.\n\n" +
|
||||
"Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
|
||||
"**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
|
||||
"Put the complete message in `message`. Markdown and LaTeX are supported. " +
|
||||
"Do not duplicate it in ordinary response text.\n\n" +
|
||||
"Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
|
||||
"commentary through `send_to_user` before calling that tool.\n\n" +
|
||||
"This tool does not pause or end your turn. Continue with other tools when needed. " +
|
||||
"After delivering your final message, end the turn without additional ordinary response text.",
|
||||
parameters: sendToUserInput,
|
||||
executionMode: "sequential",
|
||||
execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Read-only provenance for historical question cards. Never persist this
|
||||
* projection: answer identities, ordering, grading, and learner text remain
|
||||
* the recorded facts; only model-authored display text comes from the call. */
|
||||
import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
|
||||
import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
|
||||
import { repairMathForDisplay } from "./math-delims.ts";
|
||||
|
||||
export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
|
||||
if (answer.dismissed) return answer;
|
||||
const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
|
||||
const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
|
||||
const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
|
||||
if (!source) return answer;
|
||||
const projected = { ...answer, questionText: source.question };
|
||||
if (quiz) projected.explanation = quiz.explanation;
|
||||
if (explain) projected.committed = explain.correctAnswer;
|
||||
if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
|
||||
|
||||
const original = normalizeOptions(source.options);
|
||||
const saved = answer.options;
|
||||
const bindings = new Map<number, number>(); // saved position -> original position
|
||||
const used = new Set<number>();
|
||||
const bindUnique = (candidates: number[][]) => {
|
||||
for (let si = 0; si < saved.length; si++) {
|
||||
if (bindings.has(si)) continue;
|
||||
const possible = candidates[si]!.filter((oi) => !used.has(oi));
|
||||
if (possible.length !== 1) continue;
|
||||
const oi = possible[0]!;
|
||||
// Two saved rows claiming the same source are ambiguous, even if
|
||||
// each row independently has just one candidate.
|
||||
if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
|
||||
bindings.set(si, oi);
|
||||
used.add(oi);
|
||||
}
|
||||
};
|
||||
// Selected-answer values are stable identities when the record retained
|
||||
// them. Do not normalize those values, recompute a shuffle, or regrade.
|
||||
bindUnique(saved.map((option) => {
|
||||
const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
|
||||
return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
|
||||
}));
|
||||
const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
|
||||
bindUnique(saved.map((option) => original.flatMap((o, oi) =>
|
||||
o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
|
||||
// A bijection with one remaining pair has no ordering ambiguity. With
|
||||
// multiple unmatched rows, keep their snapshots rather than guess.
|
||||
if (saved.length === original.length && bindings.size === saved.length - 1) {
|
||||
const si = saved.findIndex((_, i) => !bindings.has(i));
|
||||
const oi = original.findIndex((_, i) => !used.has(i));
|
||||
bindings.set(si, oi);
|
||||
}
|
||||
projected.options = saved.map((option, si) => {
|
||||
const oi = bindings.get(si);
|
||||
return oi === undefined ? option : { ...option, label: original[oi]!.label };
|
||||
});
|
||||
return projected;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Shared provenance only: no credential-store reads in the agent layer. */
|
||||
interface AuthBoot {
|
||||
/** Names owned by the original launch environment. */
|
||||
realEnv: Set<string>;
|
||||
/** Names reflected into process.env by LearnHarness's key store. */
|
||||
fillGap: Set<string>;
|
||||
}
|
||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||
|
||||
export function authEnvRecord(): AuthBoot {
|
||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
|
||||
/** External CLIs own their authentication. Never pass LearnHarness-only injections. */
|
||||
export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const result = { ...env };
|
||||
const { realEnv, fillGap } = authEnvRecord();
|
||||
for (const name of fillGap) {
|
||||
if (!realEnv.has(name)) delete result[name];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+15
-1
@@ -122,6 +122,19 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
|
||||
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* THE single custom endpoint's identity: a user-configured OpenAI-compatible
|
||||
* server registered into pi-ai at runtime (server/custom-provider.ts). NOT part
|
||||
* of AUTH_PROVIDER_KEYS (that table is test-pinned to pi-ai's built-ins and is
|
||||
* env-var-keyed); this one is keyed by a user-supplied base URL, and its key
|
||||
* rides this reserved env var (optional — keyless local servers allowed).
|
||||
*/
|
||||
export const CUSTOM_PROVIDER = {
|
||||
id: "custom",
|
||||
envVar: "CUSTOM_OPENAI_API_KEY",
|
||||
label: "Custom endpoint",
|
||||
} as const;
|
||||
|
||||
/** Every distinct env var a model-provider key rides in (the write side's
|
||||
* writability check — see isProviderEnvVar below; also drives which
|
||||
* PRESENT provider vars surface as status rows). */
|
||||
@@ -194,7 +207,8 @@ export interface AuthKeyStatus {
|
||||
info: AuthKeyInfo | null;
|
||||
/** A key is present when EITHER the environment or the file has it. */
|
||||
present: boolean;
|
||||
/** The masked tail + character count, e.g. "…ab12 (64 chars)". */
|
||||
/** Ordinary masked tail + count, e.g. "…ab12 (64 chars)"; custom keys
|
||||
* use full redaction, with no credential characters at any length. */
|
||||
masked: string | null;
|
||||
/** Where the live value comes from, given env-wins precedence. */
|
||||
source: "env" | "file" | "none";
|
||||
|
||||
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
|
||||
};
|
||||
}
|
||||
|
||||
/** The ACTIVE compaction marker: the newest one in the record (chained
|
||||
* compactions — the last one rules; older markers fall inside the
|
||||
* dropped span and never render). */
|
||||
/** The ACTIVE compaction marker: the newest one with a retained-message
|
||||
* id. Its summary supersedes older markers even when their stored
|
||||
* positions remain inside its retained tail. */
|
||||
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/** Source-preserving boundaries for tool-authored Markdown. Recovery never
|
||||
* mutates input, reserializes Markdown, or enters protected literal content. */
|
||||
import katex from "katex";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { mathFromMarkdown } from "mdast-util-math";
|
||||
import { math } from "micromark-extension-math";
|
||||
|
||||
export type ContentSpan =
|
||||
| { kind: "prose" | "code"; raw: string }
|
||||
| { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
|
||||
const blankLine = /^\r?\n[ \t]*\r?\n/;
|
||||
const escaped = (text: string, i: number) => {
|
||||
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||
return n % 2 === 1;
|
||||
};
|
||||
const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
|
||||
type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
|
||||
/** Use the rendering stack's actual CommonMark+math grammar for literal
|
||||
* ranges, including nested quotes/lists and implicit container endings.
|
||||
* Keep source offsets: never serialize the AST back into Markdown. */
|
||||
function literalRanges(text: string): Array<{ start: number; end: number }> {
|
||||
const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
while (pending.length) {
|
||||
const node = pending.pop()!;
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset, end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) ranges.push({ start, end });
|
||||
} else if (node.children) pending.push(...node.children);
|
||||
}
|
||||
return ranges.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
|
||||
* with code and blank paragraphs as inline boundaries. */
|
||||
export function readContentSpans(text: string): ContentSpan[] {
|
||||
const spans: ContentSpan[] = [];
|
||||
const literals = literalRanges(text);
|
||||
let literal = 0, i = 0, plain = 0;
|
||||
const opaque = (end: number) => {
|
||||
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||
spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
|
||||
};
|
||||
while (i < text.length) {
|
||||
while (literal < literals.length && literals[literal]!.end <= i) literal++;
|
||||
const barrier = literals[literal]?.start ?? Infinity;
|
||||
if (i >= barrier) { opaque(literals[literal]!.end); continue; }
|
||||
let open = "", close = "", display = false, after = -1, at = -1;
|
||||
if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
|
||||
open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
|
||||
for (let j = i + 2; j < text.length - 1; j++) {
|
||||
if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
|
||||
if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
|
||||
}
|
||||
} else if (text[i] === "$" && !escaped(text, i)) {
|
||||
const size = runLength(text, i, "$"); open = "$".repeat(size);
|
||||
const eol = text.indexOf("\n", i + size);
|
||||
const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||
display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
|
||||
if (display) {
|
||||
let start = eol;
|
||||
while (start >= 0 && start < text.length) {
|
||||
start++;
|
||||
const next = text.indexOf("\n", start);
|
||||
const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
|
||||
if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
|
||||
start = next;
|
||||
}
|
||||
} else {
|
||||
for (let j = i + size; j < text.length;) {
|
||||
if (j >= barrier || blankLine.test(text.slice(j))) break;
|
||||
if (text[j] === "$" && !escaped(text, j)) {
|
||||
const n = runLength(text, j, "$");
|
||||
if (n === size) { at = j; close = open; after = j + n; break; }
|
||||
j += n;
|
||||
} else j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (at >= 0) {
|
||||
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||
spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
|
||||
i = plain = after;
|
||||
} else i += open.length || 1;
|
||||
}
|
||||
if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
|
||||
return spans;
|
||||
}
|
||||
|
||||
export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
|
||||
return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
|
||||
}
|
||||
|
||||
const knownCommands = new Map<string, boolean>();
|
||||
/** Probe only a command name, never arbitrary content. Missing-argument errors
|
||||
* still identify a known command. No trust, macros, IO, or persistent TeX state. */
|
||||
export function knownTexCommand(name: string): boolean {
|
||||
if (!/^[A-Za-z]+$/.test(name)) return false;
|
||||
const cached = knownCommands.get(name); if (cached !== undefined) return cached;
|
||||
let known = true;
|
||||
try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
|
||||
catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
|
||||
knownCommands.set(name, known);
|
||||
return known;
|
||||
}
|
||||
|
||||
export function canParseTex(body: string, display: boolean): boolean {
|
||||
try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
|
||||
export function numericPercentEscape(inner: string, percent: number): boolean {
|
||||
let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||
if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
|
||||
const tail = inner.slice(percent + 1);
|
||||
// A final source-formatting LF before the closing fence is not a next
|
||||
// equation row. A meaningful following line or comment text remains literal.
|
||||
if (/^[\s.)\]}]*$/.test(tail)) return true;
|
||||
return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
|
||||
}
|
||||
|
||||
/** TeX boundaries shared by document decoding and command recovery. Native
|
||||
* rows are literal; consistently extra-escaped environment pairs supply an
|
||||
* explicit escaping factor for both commands AND row separators. */
|
||||
export function texRanges(inner: string): TexRange[] {
|
||||
const ranges: TexRange[] = [];
|
||||
const stack: Array<{ name: string; start: number; factor: number }> = [];
|
||||
let i = 0;
|
||||
while (i < inner.length) {
|
||||
if (inner[i] === "%" && !escaped(inner, i)) {
|
||||
let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||
const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
|
||||
const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
|
||||
const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
|
||||
if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
|
||||
const newline = inner.indexOf("\n", i);
|
||||
const end = newline < 0 ? inner.length : newline + 1;
|
||||
ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
|
||||
}
|
||||
if (inner[i] !== "\\") { i++; continue; }
|
||||
let end = i + 1; while (inner[end] === "\\") end++;
|
||||
const count = end - i;
|
||||
const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
|
||||
if (marker) {
|
||||
const factor = count % 2 === 1 ? 1 : count;
|
||||
if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
|
||||
else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
|
||||
const open = stack.pop()!;
|
||||
ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
|
||||
}
|
||||
i = end + marker[0].length; continue;
|
||||
}
|
||||
// KaTeX's substack macro expands its braced argument into subarray:
|
||||
// these are row separators even without literal begin/end markers.
|
||||
const substack = /^substack\s*\{/.exec(inner.slice(end));
|
||||
if (substack) {
|
||||
let depth = 1, j = end + substack[0].length;
|
||||
for (; j < inner.length && depth > 0; j++) {
|
||||
if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
|
||||
if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
|
||||
}
|
||||
if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return ranges.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
export function outsideTexComments(body: string, transform: (part: string) => string): string {
|
||||
let from = 0; const out: string[] = [];
|
||||
for (const range of texRanges(body).filter(r => r.kind === "comment")) {
|
||||
out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
|
||||
}
|
||||
out.push(transform(body.slice(from))); return out.join("");
|
||||
}
|
||||
|
||||
/** Document escapes are not arbitrary string decoding. Complete known TeX
|
||||
* words win over the prefix `\\n`; code is never inspected or rewritten. */
|
||||
export function recoverDocumentLayout(text: string): string {
|
||||
const spans = readContentSpans(text);
|
||||
const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
|
||||
const declared = new Set([
|
||||
...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
|
||||
...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
|
||||
].map(m => m[1]!));
|
||||
const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
|
||||
const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
|
||||
const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
|
||||
const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
|
||||
[...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
|
||||
if (!structural) return text;
|
||||
const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
|
||||
if (slashes.length % 2 === 0 || knownN(name)) return whole;
|
||||
const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
|
||||
if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
|
||||
return slashes.slice(0, -1) + "\n" + name.slice(1);
|
||||
});
|
||||
const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
|
||||
let out = "";
|
||||
for (let i = 0; i < projected.length; i++) {
|
||||
const { span, body } = projected[i]!;
|
||||
if (span.kind !== "math") { out += body; continue; }
|
||||
// Decoding document layout can expose a flow opener whose close was
|
||||
// written on the formula line. Frame BOTH ends; otherwise Markdown
|
||||
// consumes the closing dollars as TeX and introduces a rendering error.
|
||||
if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
|
||||
if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
|
||||
out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
|
||||
const next = projected[i + 1];
|
||||
if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
|
||||
} else out += span.open + body + span.close;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const CLAUDE_CODE_PROVIDER = "claude-code";
|
||||
|
||||
/** Installation/version only. Never an authentication, quota, or billing verdict. */
|
||||
export interface ClaudeCodeStatus {
|
||||
state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked";
|
||||
version: string | null;
|
||||
minimumVersion: string;
|
||||
}
|
||||
|
||||
// --permission-prompts none was added in 2.1.259. This is not an official
|
||||
// minimum-version/stability contract for claudecode/toolUseId; admission fails closed.
|
||||
export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259";
|
||||
+28
-32
@@ -21,6 +21,12 @@ export interface JobRecord {
|
||||
chatId: string;
|
||||
/** The tool part this job fulfills. */
|
||||
toolCallId: string;
|
||||
/** Dispatch-time arguments, saved before spawn. Older jobs resolve from history. */
|
||||
input?: unknown;
|
||||
/** Session-owned delivery, independent of the worker's terminal outcome.
|
||||
* Absent on legacy records: old terminal jobs must never be replayed. */
|
||||
delivery?: { kind: "result" | "abort"; status: "pending" | "delivered" };
|
||||
/** Worker computation only; a pending delivery does not keep it running. */
|
||||
status: JobStatus;
|
||||
/** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */
|
||||
activity: string;
|
||||
@@ -148,42 +154,32 @@ export function accumulateJobUsage(
|
||||
* sourced briefs) — tracking a live activity line in the job record.
|
||||
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
|
||||
* job surface: the transcript dispatch cards (live activity by
|
||||
* toolCallId, quiet one-liner after delivery), the sub-agents sidebar
|
||||
* toolCallId, quiet one-liner after worker completion), the sub-agents sidebar
|
||||
* panel, and the single re-sync when a job settles.
|
||||
*
|
||||
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
||||
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
||||
* result-carrying native marker — the content is what the MODEL needs
|
||||
* (the research brief, the builder's notes), plus a data-subagent-artifact
|
||||
* part with what the UI renders (the full result: brief + sources /
|
||||
* SVG / demo HTML). Provider conversion drops data parts, so artifact
|
||||
* payloads never enter the teacher's context. metadata.subagentResult
|
||||
* identifies the job; the UI renders these as sub-agent result cards,
|
||||
* NEVER as learner interjections.
|
||||
* 3. Dispatch saves input on the job before spawn. On completion the
|
||||
* runner saves result + terminal worker status + pending delivery in
|
||||
* one job update, then queues buildSubagentSteer WITHOUT waiting for
|
||||
* delivery. The native custom marker's content is model-facing text;
|
||||
* details carry the full artifact payload for the UI. Worker resources
|
||||
* are released even if a question keeps the result pending indefinitely.
|
||||
* Only computing workers appear in Background agents or accept abort.
|
||||
*
|
||||
* 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
|
||||
* turn through the same turn composition as a client request
|
||||
* (handleChatRequest) — write-ahead persist, turn liveness, extraction
|
||||
* trigger: one code path. A per-chat serialized queue: a steer waits
|
||||
* while a turn is in flight OR a question tool part is pending (an
|
||||
* unanswered client-side tool call breaks provider conversion — the
|
||||
* steer fires right after the learner's continuation), then fires; a 409
|
||||
* (a client turn raced in) retries. Delivery is idempotent per jobId, so
|
||||
* a crashed wake can be re-entered by the stale-job recovery sweep. The
|
||||
* job's status flips to done/error only AFTER the wake turn completes —
|
||||
* a polling client that sees the flip re-syncs once and gets the
|
||||
* finished record.
|
||||
* 4. Session delivery owns one registry entry per deterministic steer ID,
|
||||
* including while a wake is in flight or a 409/403 returns its cargo.
|
||||
* A live agent takes it via pi steering; registration restores saved
|
||||
* pending jobs before the answer run's first poll. Otherwise the idle
|
||||
* wake uses the normal turn composition and respects pending questions,
|
||||
* dismissal, and background consent. A saved transcript marker completes
|
||||
* delivery; HTTP 200 or streamed visibility alone does not.
|
||||
*
|
||||
* CONTINUATION COALESCING: the idle window above can starve — a
|
||||
* quiz-chaining teacher ends every turn with a new pending check, so
|
||||
* the gates never stay open long enough for a wake to fire. The fix:
|
||||
* when a client turn wins the turn registry, the handler CLAIMS every
|
||||
* queued, undelivered steer (jobs.ts claimSteers) and appends it after
|
||||
* the learner's answer in that very turn — conversion-safe, because the
|
||||
* answered quiz carries its output. Every answer flushes pending
|
||||
* steers immediately; the wake pump remains for turns that end with no
|
||||
* pending question. The pump re-checks steerDelivered right before
|
||||
* firing, so a claim racing the pump converges to exactly one delivery.
|
||||
* Failed starts remain quietly pending until a learner run or restart
|
||||
* recovery, without new timers, visible delivery errors, or worker-status
|
||||
* changes. Existing 409 backoff and consent deferral remain. Accepted
|
||||
* turns are never automatically replayed. Legacy running records with a
|
||||
* result become finished/pending; legacy terminal records with no
|
||||
* delivery metadata remain untouched. Session deletion discards pending
|
||||
* deliveries rather than treating them as running work.
|
||||
*
|
||||
* 5. No pending job parts ever exist, so the composer never gates on jobs
|
||||
* (the learner can interject mid-job; the steer queues behind their
|
||||
|
||||
@@ -21,9 +21,11 @@ export interface ModelCatalogEntry {
|
||||
* thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking
|
||||
* picker lists exactly these; anything else is a lie. */
|
||||
supportedLevels: ThinkingLevel[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
/** Null when LearnHarness cannot estimate prices for this connection. */
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null;
|
||||
/** Null for native CLI aliases whose effective limits are session-specific. */
|
||||
contextWindow: number | null;
|
||||
maxTokens: number | null;
|
||||
}
|
||||
|
||||
/** The conversation agents' resolved default (BYOK, owner 2026-09-22): the
|
||||
@@ -78,8 +80,8 @@ export interface ModelCatalogResponse {
|
||||
/** One row of the providers surface. */
|
||||
export interface CatalogProvider {
|
||||
id: string;
|
||||
/** The env var whose presence federates this provider in. */
|
||||
/** The gating env var; empty for subscription and file-configured routes. */
|
||||
envVar: string;
|
||||
/** Whether that var is currently present (env or auth file). */
|
||||
/** Whether credentials are present (and custom references resolve). */
|
||||
keyPresent: boolean;
|
||||
}
|
||||
+25
-27
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
|
||||
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
|
||||
* text blocks — no learner messages, no thinking, no tool calls or
|
||||
* results, no quiz blocks. Everything else is INVISIBLE to coverage: it
|
||||
* neither opens nor joins a run, so answering a quiz or editing a learner
|
||||
* prose — ordinary text blocks and explicit send_to_user messages. No
|
||||
* learner messages, thinking, other tool calls/results, or quiz blocks.
|
||||
* Everything else is INVISIBLE to coverage: it neither opens nor joins a run, so answering a quiz or editing a learner
|
||||
* message triggers nothing. "Covered" is by construction exactly "what
|
||||
* the extractor saw": the fingerprint (sha256 of a message's view lines)
|
||||
* and the extractor's prompt share ONE selection — the teacher prose.
|
||||
@@ -22,33 +22,27 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
import { failedOutputCallIds, userFacingTextOf, type LhMessage } from "./native-messages.ts";
|
||||
|
||||
export type { CoverageEntry };
|
||||
|
||||
/** The user-facing text of a native message (string or text blocks). */
|
||||
function textOf(content: unknown): string[] {
|
||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
||||
.map((c) => c.text)
|
||||
.filter((t) => t.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The extraction-relevant lines of ONE native message — the shared seam
|
||||
* between the extractor's prompt and messageFingerprint (coverage).
|
||||
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
|
||||
* line per text block; everything else (user messages, tool results,
|
||||
* markers) yields NOTHING and is invisible to coverage. `mid` is the
|
||||
* message id — part of the hash input, so the same prose in two messages
|
||||
* line per learner-facing prose block; everything else (user messages,
|
||||
* tool results, markers) yields NOTHING and is invisible to coverage.
|
||||
* `mid` is the message id — part of the hash input, so the same prose in two messages
|
||||
* is two slots (per-message coverage).
|
||||
*/
|
||||
export function messageViewLines(message: LhMessage, mid: string): string[] {
|
||||
export function messageViewLines(
|
||||
message: LhMessage,
|
||||
mid: string,
|
||||
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds([message]),
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
if ((message as { role?: string }).role !== "assistant") return lines;
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) {
|
||||
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
||||
}
|
||||
return lines;
|
||||
@@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] {
|
||||
*/
|
||||
export function teacherProseBlocks(messages: ReadonlyArray<LhMessage>): string[] {
|
||||
const out: string[] = [];
|
||||
const failedOutputCalls = failedOutputCallIds(messages);
|
||||
for (const message of messages) {
|
||||
if ((message as { role?: string }).role !== "assistant") continue;
|
||||
for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim());
|
||||
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) out.push(text.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number {
|
||||
* teacher prose, with the message id as the tag). Changes exactly when
|
||||
* the teacher prose of the message changes — nothing else can re-open it.
|
||||
*/
|
||||
export function messageFingerprint(message: LhMessage): string {
|
||||
export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet<string>): string {
|
||||
const h = createHash("sha256");
|
||||
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)]));
|
||||
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id, failedOutputCalls)]));
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
@@ -95,7 +90,12 @@ export interface CoverageSlot {
|
||||
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
|
||||
* and the coverage view — one derivation everywhere). */
|
||||
export function coverageSlots(messages: ReadonlyArray<LhMessage>): CoverageSlot[] {
|
||||
return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) }));
|
||||
const failedOutputCalls = failedOutputCallIds(messages);
|
||||
return messages.map((message) => ({
|
||||
message,
|
||||
fp: messageFingerprint(message, failedOutputCalls),
|
||||
tokens: viewTokens(messageViewLines(message, message.id, failedOutputCalls)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The covered-fingerprint set for one chat, from the ledger. */
|
||||
@@ -131,16 +131,14 @@ export function coverageOf(
|
||||
let uncoveredTokens = 0;
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i]!;
|
||||
if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible
|
||||
if (slot.tokens === 0) continue; // invisible (including failed output calls)
|
||||
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
|
||||
else {
|
||||
uncovered.push(i);
|
||||
uncoveredTokens += slot.tokens;
|
||||
}
|
||||
}
|
||||
const visibleCount = slots.length - slots.filter(
|
||||
(s) => messageViewLines(s.message, s.message.id).length === 0,
|
||||
).length;
|
||||
const visibleCount = slots.filter((s) => s.tokens > 0).length;
|
||||
return {
|
||||
slots,
|
||||
uncovered,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Private custom credential authorization; no credential-derived public value. */
|
||||
import { normalizeBaseUrl, type CustomProviderSettings } from "./settings.ts";
|
||||
|
||||
export const CUSTOM_AUTH_BINDING = "__learnharness_custom_provider_binding";
|
||||
export interface CustomAuthBinding {
|
||||
version: 1;
|
||||
id: string;
|
||||
baseUrl?: string;
|
||||
authMode: "apiKey" | "keyless" | "disabled";
|
||||
/** A shadowed file key keeps its own destination when an env key moves. */
|
||||
fileKeyBaseUrl?: string;
|
||||
}
|
||||
export function parseCustomAuthBinding(value: unknown): CustomAuthBinding | undefined {
|
||||
try {
|
||||
const b = typeof value === "string" ? JSON.parse(value) : undefined;
|
||||
if (!b || b.version !== 1 || typeof b.id !== "string" || !/^[a-f0-9]{32}$/.test(b.id)) return;
|
||||
if (!["apiKey", "keyless", "disabled"].includes(b.authMode)) return;
|
||||
if (b.authMode !== "disabled" && b.baseUrl === undefined) return;
|
||||
if (b.baseUrl !== undefined && (typeof b.baseUrl !== "string" || !b.baseUrl || normalizeBaseUrl(b.baseUrl) !== b.baseUrl)) return;
|
||||
if (b.fileKeyBaseUrl !== undefined && (typeof b.fileKeyBaseUrl !== "string" || !b.fileKeyBaseUrl || normalizeBaseUrl(b.fileKeyBaseUrl) !== b.fileKeyBaseUrl)) return;
|
||||
return { version: 1, id: b.id, authMode: b.authMode, ...(b.baseUrl !== undefined ? { baseUrl: b.baseUrl } : {}), ...(b.fileKeyBaseUrl !== undefined ? { fileKeyBaseUrl: b.fileKeyBaseUrl } : {}) };
|
||||
} catch { return; }
|
||||
}
|
||||
export function customBindingMatches(cp: CustomProviderSettings | undefined, binding: CustomAuthBinding | undefined): boolean {
|
||||
return Boolean(cp?.baseUrl && binding && binding.authMode !== "disabled" && cp.credentialBindingId === binding.id && cp.baseUrl === binding.baseUrl && cp.authMode === binding.authMode);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Nova-3 monolingual language codes, including documented locale aliases.
|
||||
* Source: https://developers.deepgram.com/docs/models-languages-overview
|
||||
* Multilingual is a separate mode; its supported language set differs.
|
||||
*/
|
||||
const NOVA_3_LANGUAGES = [
|
||||
["Afrikaans", ["af", "af-ZA"]],
|
||||
["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]],
|
||||
["Armenian", ["hy"]],
|
||||
["Assamese", ["as", "as-IN"]],
|
||||
["Belarusian", ["be"]],
|
||||
["Bengali", ["bn"]],
|
||||
["Bosnian", ["bs"]],
|
||||
["Bulgarian", ["bg"]],
|
||||
["Catalan", ["ca"]],
|
||||
["Chinese (Cantonese, Traditional)", ["zh-HK"]],
|
||||
["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]],
|
||||
["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]],
|
||||
["Croatian", ["hr"]],
|
||||
["Czech", ["cs", "cs-CZ"]],
|
||||
["Danish", ["da", "da-DK"]],
|
||||
["Dutch", ["nl"]],
|
||||
["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]],
|
||||
["Estonian", ["et"]],
|
||||
["Finnish", ["fi"]],
|
||||
["Flemish", ["nl-BE"]],
|
||||
["French", ["fr", "fr-CA"]],
|
||||
["Georgian", ["ka", "ka-GE"]],
|
||||
["German", ["de"]],
|
||||
["German (Switzerland)", ["de-CH"]],
|
||||
["Greek", ["el"]],
|
||||
["Gujarati", ["gu", "gu-IN"]],
|
||||
["Hebrew", ["he"]],
|
||||
["Hindi", ["hi"]],
|
||||
["Hungarian", ["hu"]],
|
||||
["Indonesian", ["id"]],
|
||||
["Italian", ["it"]],
|
||||
["Japanese", ["ja"]],
|
||||
["Kannada", ["kn"]],
|
||||
["Kazakh", ["kk", "kk-KZ"]],
|
||||
["Korean", ["ko", "ko-KR"]],
|
||||
["Latvian", ["lv"]],
|
||||
["Lithuanian", ["lt"]],
|
||||
["Macedonian", ["mk"]],
|
||||
["Malay", ["ms"]],
|
||||
["Marathi", ["mr"]],
|
||||
["Mongolian", ["mn"]],
|
||||
["Nepali", ["ne"]],
|
||||
["Norwegian", ["no"]],
|
||||
["Pashto", ["ps", "ps-AF"]],
|
||||
["Persian", ["fa"]],
|
||||
["Polish", ["pl"]],
|
||||
["Portuguese", ["pt", "pt-BR", "pt-PT"]],
|
||||
["Punjabi", ["pa", "pa-IN"]],
|
||||
["Romanian", ["ro"]],
|
||||
["Russian", ["ru"]],
|
||||
["Serbian", ["sr"]],
|
||||
["Slovak", ["sk"]],
|
||||
["Slovenian", ["sl"]],
|
||||
["Spanish", ["es", "es-419"]],
|
||||
["Swedish", ["sv", "sv-SE"]],
|
||||
["Tagalog", ["tl"]],
|
||||
["Tamil", ["ta"]],
|
||||
["Telugu", ["te"]],
|
||||
["Thai", ["th", "th-TH"]],
|
||||
["Turkish", ["tr", "tr-TR"]],
|
||||
["Ukrainian", ["uk"]],
|
||||
["Urdu", ["ur"]],
|
||||
["Vietnamese", ["vi"]],
|
||||
] as const;
|
||||
|
||||
export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number];
|
||||
|
||||
export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [
|
||||
{ code: "multi", label: "Multilingual" },
|
||||
...NOVA_3_LANGUAGES.flatMap(([name, codes]) =>
|
||||
codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })),
|
||||
).sort((a, b) => a.label.localeCompare(b.label, "en")),
|
||||
];
|
||||
|
||||
export function isDictationLanguage(value: unknown): value is DictationLanguage {
|
||||
return DICTATION_LANGUAGES.some(({ code }) => code === value);
|
||||
}
|
||||
|
||||
/** Only language varies. Keep the owner's tuned streaming parameters. */
|
||||
export function deepgramListenUrl(language: DictationLanguage): string {
|
||||
return "wss://api.deepgram.com/v1/listen" +
|
||||
"?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" +
|
||||
"&smart_format=true&punctuate=true&endpointing=300" +
|
||||
`&language=${encodeURIComponent(language)}`;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface ServerEvents {
|
||||
"activity-changed": Record<string, never>;
|
||||
/** The session list changed (create, delete, title). */
|
||||
"chats-changed": Record<string, never>;
|
||||
/** Web subscription state changed. No account or credential data on the bus. */
|
||||
"subscriptions-changed": Record<string, never>;
|
||||
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||
* Same notify-then-fetch contract as the turn events: open views of
|
||||
* the chat refetch and adopt the durable record. */
|
||||
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
|
||||
"jobs-changed",
|
||||
"activity-changed",
|
||||
"chats-changed",
|
||||
"subscriptions-changed",
|
||||
"record-changed",
|
||||
"compaction-changed",
|
||||
] as const satisfies readonly ServerEventType[];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** A lossless projection of plain learner text, not a Markdown parser. */
|
||||
export type FilePathPart =
|
||||
| { kind: "text"; text: string; start: number; end: number }
|
||||
| { kind: "path"; text: string; path: string; start: number; end: number };
|
||||
|
||||
// No filesystem access or normalization: even punctuation and backslashes can
|
||||
// be legitimate POSIX filename characters. Only rooted syntax is required.
|
||||
function isFilePath(path: string): boolean {
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(path)) return false;
|
||||
return path.startsWith("/")
|
||||
|| path.startsWith("~/")
|
||||
|| /^[a-z]:[\\/]/i.test(path)
|
||||
|| /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a rooted path for storage/transmission as ordinary, lossless text.
|
||||
* CommonMark's one-space padding prevents edge backticks joining delimiters.
|
||||
*/
|
||||
export function quoteFilePath(path: string): string {
|
||||
if (!isFilePath(path)) throw new TypeError("Expected a single-line rooted file path");
|
||||
let width = 1;
|
||||
for (const run of path.matchAll(/`+/g)) width = Math.max(width, run[0].length + 1);
|
||||
const delimiter = "`".repeat(width);
|
||||
const padding = path.startsWith("`") || path.endsWith("`") ? " " : "";
|
||||
return `${delimiter}${padding}${path}${padding}${delimiter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize every complete backtick-wrapped rooted path, even beside words.
|
||||
* Delimiters are whole runs of exactly equal length; a run inside another
|
||||
* code span is never reconsidered as a partial path token. Offsets are JS
|
||||
* string (UTF-16) offsets and `text` always slices the exact original input.
|
||||
*/
|
||||
export function parseFilePaths(text: string): FilePathPart[] {
|
||||
const runs = Array.from(text.matchAll(/`+/g), (match) => ({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
width: match[0].length,
|
||||
}));
|
||||
// The next same-width run lets unmatched openers remain plain text without
|
||||
// repeatedly scanning the rest of a long draft.
|
||||
const next = new Map<number, number>();
|
||||
const closing = new Map<number, number>();
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
const width = runs[i]!.width;
|
||||
const match = next.get(width);
|
||||
if (match !== undefined) closing.set(i, match);
|
||||
next.set(width, i);
|
||||
}
|
||||
|
||||
const parts: FilePathPart[] = [];
|
||||
let plainStart = 0;
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const closeIndex = closing.get(i);
|
||||
if (closeIndex === undefined) continue;
|
||||
const open = runs[i]!;
|
||||
const close = runs[closeIndex]!;
|
||||
let path = text.slice(open.end, close.start);
|
||||
// CommonMark strips exactly one space at each edge, not all whitespace.
|
||||
if (path.startsWith(" ") && path.endsWith(" ") && /[^ ]/.test(path)) {
|
||||
path = path.slice(1, -1);
|
||||
}
|
||||
if (isFilePath(path)) {
|
||||
if (plainStart < open.start) {
|
||||
parts.push({ kind: "text", text: text.slice(plainStart, open.start), start: plainStart, end: open.start });
|
||||
}
|
||||
parts.push({ kind: "path", text: text.slice(open.start, close.end), path, start: open.start, end: close.end });
|
||||
plainStart = close.end;
|
||||
}
|
||||
i = closeIndex;
|
||||
}
|
||||
if (plainStart < text.length) {
|
||||
parts.push({ kind: "text", text: text.slice(plainStart), start: plainStart, end: text.length });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Basename for display only; the original full path remains untouched. */
|
||||
export function filePathLabel(path: string): string {
|
||||
const windows = /^[a-z]:[\\/]/i.test(path) || path.startsWith("\\\\");
|
||||
const separator = windows ? /[\\/]/ : /\//;
|
||||
const trimmed = windows ? path.replace(/[\\/]+$/, "") : path.replace(/\/+$/, "");
|
||||
if (!trimmed || (windows && /^[a-z]:$/i.test(trimmed)) || trimmed === "~") return path;
|
||||
return trimmed.split(separator).at(-1) || path;
|
||||
}
|
||||
@@ -42,22 +42,27 @@ export * from "./math-delims.ts";
|
||||
export * from "./native-messages.ts";
|
||||
export * from "./compaction.ts";
|
||||
export * from "./input-events.ts";
|
||||
export { parseFilePaths, quoteFilePath, filePathLabel } from "./file-paths.ts";
|
||||
export type { FilePathPart } from "./file-paths.ts";
|
||||
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
|
||||
// canon (one continuous assistant document, step separators) ported from
|
||||
// the deleted server projection to the client side. Pure, browser-safe.
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./answer-presentation.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./context-usage.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
|
||||
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||
export * from "./settings.ts";
|
||||
export * from "./dictation.ts";
|
||||
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port
|
||||
// driven — the deletion RUNS server-side only. The shell needs exactly one
|
||||
// two type-only things from it: the ChatDeleteMode union (the delete
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
* continue — run the next turn on the current record (mount
|
||||
* continuations, job-settle re-syncs: the record ends at an
|
||||
* answered question with no teacher reaction yet).
|
||||
* retry — retry a failed model response (issue #110), without new
|
||||
* input. Failed attempts stay in the record; only the retry's
|
||||
* temporary agent history omits the trailing failures.
|
||||
*
|
||||
* (The plan verdict input died 2026-09-26 with the verdict itself — the
|
||||
* learner's post-plan reply is a natural text message.)
|
||||
@@ -45,6 +48,8 @@ export type ChatInput =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||
| { type: "continue" }
|
||||
/** The failed-turn retry (issue #110): the banner's Retry button. */
|
||||
| { type: "retry" }
|
||||
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
|
||||
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
|
||||
* whether it claims an answer is read from the text. One
|
||||
@@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput {
|
||||
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
|
||||
case "continue":
|
||||
return { type: "continue" };
|
||||
case "retry":
|
||||
return { type: "retry" };
|
||||
case "thinking":
|
||||
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
|
||||
throw new ChatInputError("thinking input requires problemCallId");
|
||||
@@ -210,6 +217,10 @@ export function inputAppends(
|
||||
}
|
||||
case "continue":
|
||||
return dismissalConclusion(record);
|
||||
case "retry":
|
||||
// The input already landed write-ahead. retryDrops affects only
|
||||
// the temporary agent history — nothing new appends here.
|
||||
return [];
|
||||
case "thinking": {
|
||||
const active = activeProblemOf(record.messages);
|
||||
if (!active || active.toolCallId !== input.problemCallId) {
|
||||
@@ -233,6 +244,61 @@ export function inputAppends(
|
||||
}
|
||||
}
|
||||
|
||||
/** The failure owning the ephemeral banner. Plan presentation and
|
||||
* compaction do not resolve an error; substantive later input/output does.
|
||||
* This is deliberately broader than Retry eligibility below. */
|
||||
export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract<LhMessage, { role: "assistant" }> | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue;
|
||||
return m.role === "assistant" && m.stopReason === "error" ? m : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Does the record end in a failure with input that can be retried?
|
||||
* Aborts are deliberate stops; compaction after a failure requires new
|
||||
* learner input rather than a retry using a changed context. */
|
||||
export function endsInFailedTurn(messages: readonly LhMessage[]): boolean {
|
||||
const { ids, before } = failedTail(messages);
|
||||
return ids.size > 0 && before !== undefined && before.role !== "assistant";
|
||||
}
|
||||
|
||||
/** The ids omitted ONLY from the retry's temporary agent history.
|
||||
* Failed attempts (including partial text and their ids) stay on disk.
|
||||
* Pi's provider conversion already skips error/aborted responses, but
|
||||
* continue() rejects an assistant tail BEFORE conversion. Omitting the
|
||||
* trailing failures lets that continuation start from the saved input or
|
||||
* completed tool results without replaying tools or resubmitting input.
|
||||
* Plan blocks may follow the failure and stay; later compaction blocks
|
||||
* Retry without changing compaction itself. */
|
||||
export function retryDrops(messages: readonly LhMessage[]): Set<string> {
|
||||
const { ids, before } = failedTail(messages);
|
||||
if (ids.size === 0) {
|
||||
const failure = failedResponseAtEnd(messages);
|
||||
if (failure) {
|
||||
throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`);
|
||||
}
|
||||
throw new ChatInputError("retry input requires a failed turn to retry");
|
||||
}
|
||||
if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from");
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Walk back over failed assistants and UI-only plan blocks. A compaction
|
||||
* is a boundary, not a marker Retry may cross. A failure AFTER a compaction
|
||||
* can still retry: that compacted context was already its starting state. */
|
||||
function failedTail(messages: readonly LhMessage[]): { ids: Set<string>; before: LhMessage | undefined } {
|
||||
const ids = new Set<string>();
|
||||
let i = messages.length - 1;
|
||||
for (; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id);
|
||||
else if (!(m.role === "custom" && m.customType === "plan-block")) break;
|
||||
}
|
||||
return { ids, before: messages[i] };
|
||||
}
|
||||
|
||||
/** The deferred conclude synthesis (owner ruling, 2026-10-04): a
|
||||
* problem-dismissal marker waits for the user's next input — the pair
|
||||
* (synthetic conclude call + ack) rides ahead of it, so the agent reads
|
||||
|
||||
@@ -715,6 +715,7 @@
|
||||
"Other": "Other",
|
||||
"Light": "Light",
|
||||
"delivered": "delivered",
|
||||
"finished": "finished",
|
||||
"No such session": "No such session",
|
||||
"A turn is running in this session — try again when it finishes": "A turn is running in this session — try again when it finishes",
|
||||
"Background jobs are still running in this session — try again when they finish": "Background jobs are still running in this session — try again when they finish",
|
||||
|
||||
@@ -746,6 +746,7 @@
|
||||
"Other": "Другой вариант",
|
||||
"Light": "Светлая",
|
||||
"delivered": "доставлено",
|
||||
"finished": "завершено",
|
||||
"No such session": "Такого занятия нет",
|
||||
"A turn is running in this session — try again when it finishes": "В этом занятии формируется ответ — дождитесь завершения и повторите попытку",
|
||||
"Background jobs are still running in this session — try again when they finish": "В этом занятии ещё выполняются фоновые задачи — дождитесь их завершения",
|
||||
|
||||
+107
-558
@@ -1,572 +1,121 @@
|
||||
/**
|
||||
* Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
|
||||
*
|
||||
* Current authoring uses `$...$` inline and `$$...$$` on separate lines for
|
||||
* display (owner, 2026-10-12). This pure transform keeps correctly formed
|
||||
* old backslash-delimited content readable; dollar math passes through to
|
||||
* Streamdown's native parser. Escape restoration below recognizes both
|
||||
* syntaxes while retaining the existing recovery rules.
|
||||
*
|
||||
* Rules:
|
||||
* - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
|
||||
* `\[...\]` → `$$\n...\n$$` (block form: flow math).
|
||||
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
|
||||
* - Nothing inside fenced code blocks or inline code spans is touched.
|
||||
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
|
||||
* an incomplete `\[` completes on a later chunk (the whole text is
|
||||
* re-transformed each render).
|
||||
*
|
||||
* Pure string → string. No markdown parsing — a single linear scan with a
|
||||
* code-state machine, cheap enough to run in the render path of a memo'd
|
||||
* component.
|
||||
*/
|
||||
/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
|
||||
* display-only tool-content recovery. Source arguments are never rewritten. */
|
||||
import {
|
||||
canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
|
||||
readContentSpans, recoverDocumentLayout, texRanges,
|
||||
} from "./content-reader.ts";
|
||||
|
||||
const INLINE_OPEN = "\\(";
|
||||
const INLINE_CLOSE = "\\)";
|
||||
const DISPLAY_OPEN = "\\[";
|
||||
const DISPLAY_CLOSE = "\\]";
|
||||
|
||||
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/**
|
||||
* Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
|
||||
* backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
|
||||
* followed by a bracket — not a delimiter.
|
||||
*/
|
||||
function isEscaped(text: string, i: number): boolean {
|
||||
let count = 0;
|
||||
for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
|
||||
function backtickRun(text: string, i: number): number {
|
||||
let count = 0;
|
||||
while (text[i + count] === "`") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the closing delimiter for the opening one at `i` (2 chars,
|
||||
* already verified unescaped). The close scan stops at inline code spans and
|
||||
* fence starts (barriers — math never contains them), and returns -1 when the
|
||||
* delimiter is unbalanced (mid-stream chunk).
|
||||
*/
|
||||
function findClose(
|
||||
text: string,
|
||||
from: number,
|
||||
close: string,
|
||||
): number {
|
||||
for (let j = from; j < text.length - 1; j++) {
|
||||
if (text[j] === "`") return -1; // inline code barrier
|
||||
if (
|
||||
text[j] === "\\" &&
|
||||
text[j + 1] === close[1] &&
|
||||
!isEscaped(text, j)
|
||||
) {
|
||||
return j;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** True when `i` sits at the start of a line (position 0 or right after \n). */
|
||||
function atLineStart(text: string, i: number): boolean {
|
||||
return i === 0 || text[i - 1] === "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a fenced code block starting at `i` (line start, fence regex matched)
|
||||
* verbatim to the output. Returns the index after the closing fence line —
|
||||
* or the end of the text when the fence is unclosed (mid-stream: everything
|
||||
* after is literal code until the chunk completes).
|
||||
*/
|
||||
function copyFencedBlock(text: string, i: number, out: string[]): number {
|
||||
const open = FENCE_OPEN_RE.exec(text.slice(i));
|
||||
const marker = open?.[1] ?? "```";
|
||||
const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
|
||||
let j = i;
|
||||
// Copy lines until the matching closing fence.
|
||||
while (j < text.length) {
|
||||
const lineEnd = text.indexOf("\n", j);
|
||||
const next = lineEnd === -1 ? text.length : lineEnd + 1;
|
||||
const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
|
||||
out.push(text.slice(j, next));
|
||||
if (j > i && closeRe.test(line)) return next; // closing fence line
|
||||
j = next;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
|
||||
function displayIsBlockPositioned(text: string, i: number): boolean {
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const ch = text[j];
|
||||
if (ch === "\n") return true;
|
||||
if (ch !== " " && ch !== "\t") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Is the `\[` at `i` indented (whitespace before it on the line)? */
|
||||
function displayIsIndented(text: string, i: number): boolean {
|
||||
let sawWhitespace = false;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const ch = text[j];
|
||||
if (ch === "\n") return sawWhitespace;
|
||||
if (ch !== " " && ch !== "\t") return false; // mid-paragraph
|
||||
sawWhitespace = true;
|
||||
}
|
||||
return sawWhitespace;
|
||||
}
|
||||
|
||||
/** Does the closing delimiter end its line (only \n or EOF after it)? */
|
||||
function closeEndsLine(text: string, afterClose: number): boolean {
|
||||
return afterClose >= text.length || text[afterClose] === "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
|
||||
* Everything else passes through byte-for-byte.
|
||||
*/
|
||||
/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
|
||||
* mathText closer is code-span logic — ANY size-matched `$` run closes,
|
||||
* `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
|
||||
* own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
|
||||
* fine — the author's intent is unambiguous: an escaped dollar (odd number
|
||||
* of backslashes before it) never closes the span. Mirrors findDollarClose's
|
||||
* inline rules otherwise (exact size match, whole-run skip). */
|
||||
function findInlineDollarCloseEscapeAware(text: string, from: number): number {
|
||||
for (let j = from; j < text.length;) {
|
||||
if (text[j] !== "$") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (isEscaped(text, j)) {
|
||||
j += 1;
|
||||
continue; // `\$` never closes
|
||||
}
|
||||
const count = dollarRun(text, j);
|
||||
if (count === 1) return j;
|
||||
j += count;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Replace `\$` (odd-backslash dollars) inside a math span with the
|
||||
* dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
|
||||
* `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
|
||||
* row break + dollar) are left alone. */
|
||||
function replaceEscapedDollars(content: string): string {
|
||||
let replaced = "";
|
||||
for (let j = 0; j < content.length;) {
|
||||
if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
|
||||
replaced += "\\text{\\textdollar}";
|
||||
j += 2;
|
||||
} else {
|
||||
replaced += content[j];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
export function transformMathDelimiters(markdown: string): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < markdown.length) {
|
||||
const ch = markdown[i]!;
|
||||
|
||||
// Fenced code blocks: copied verbatim (only detectable at line start).
|
||||
if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
|
||||
i = copyFencedBlock(markdown, i, out);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline code spans: copied verbatim.
|
||||
if (ch === "`") {
|
||||
const run = backtickRun(markdown, i);
|
||||
let j = i + run;
|
||||
// Find the matching run (same length, per CommonMark).
|
||||
while (j < markdown.length) {
|
||||
const nextRun = backtickRun(markdown, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
if (j < markdown.length) {
|
||||
out.push(markdown.slice(i, j + run));
|
||||
i = j + run;
|
||||
} else {
|
||||
// Unclosed run (mid-stream): literal, keep scanning after it.
|
||||
out.push(markdown.slice(i, i + run));
|
||||
i += run;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Math delimiters.
|
||||
if (ch === "\\" && !isEscaped(markdown, i)) {
|
||||
const next = markdown[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(markdown, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const content = markdown.slice(i + open.length, closeAt).trim();
|
||||
if (content.length > 0) {
|
||||
if (isDisplay && displayIsIndented(markdown, i)) {
|
||||
// Indented display math (inside a list item, quote, …):
|
||||
// an indented $$ flow block half-parses inside list
|
||||
// continuations (empty math, eaten items), so degrade to
|
||||
// the inline form — renders inline, stays inside the item.
|
||||
// mathText cannot span line endings, so collapse them.
|
||||
out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
|
||||
} else if (isDisplay) {
|
||||
// Flow math ($$ alone on its lines) renders as display
|
||||
// KaTeX. Mid-paragraph \[...\] (or trailing text after the
|
||||
// closer) would half-parse into literal dollars + KaTeX
|
||||
// errors — pad with blank lines to force a clean block.
|
||||
// Block-positioned math passes through in place.
|
||||
const afterClose = closeAt + close.length;
|
||||
const padBefore =
|
||||
displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
|
||||
const padAfter =
|
||||
closeEndsLine(markdown, afterClose) ? "" : "\n\n";
|
||||
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
|
||||
} else {
|
||||
// Preserve the legacy reader's two-dollar inline output
|
||||
// (renders inline, not display).
|
||||
out.push(`$$${content}$$`);
|
||||
}
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Unbalanced or empty: leave both characters untouched.
|
||||
}
|
||||
}
|
||||
|
||||
// Escaped dollars inside inline math (owner, 2026-10-12): normalize
|
||||
// BEFORE tokenization — inside a balanced inline span, `\$` becomes the
|
||||
// dollar-free `\text{\textdollar}` so the scanner's closer is the one
|
||||
// the author meant (micromark would otherwise close at the `\$`, error
|
||||
// out, and cascade). Display runs are copied whole and left untouched —
|
||||
// a lone `$` can't close `$$`, so `\$` already survives there — and
|
||||
// copying the run stops its second `$` posing as an inline opener.
|
||||
// Spans without escaped dollars emit byte-identical text; prose `\$`,
|
||||
// code, and unbalanced spans are untouched.
|
||||
if (ch === "$" && !isEscaped(markdown, i)) {
|
||||
const run = dollarRun(markdown, i);
|
||||
if (run === 1) {
|
||||
const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
|
||||
if (closeAt !== -1) {
|
||||
const content = markdown.slice(i + 1, closeAt);
|
||||
const replaced = replaceEscapedDollars(content);
|
||||
if (replaced !== content) {
|
||||
out.push(`$${replaced}$`);
|
||||
i = closeAt + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(markdown.slice(i, i + run));
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
|
||||
*
|
||||
* When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
|
||||
* arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
|
||||
* resolution eats the command's first letter: `\f` → form feed + "orall",
|
||||
* `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
|
||||
* the persisted RAW args and renders as garbage. Inside a MATH SPAN these
|
||||
* control chars can never be legitimate content, so restoring the two-char
|
||||
* escape reconstructs the model's intent byte-perfectly. Outside spans the
|
||||
* same chars might be real prose (pasted tables, formatted text), so only
|
||||
* balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
|
||||
* unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
|
||||
* excluding code and escaped opening dollars. Unbalanced spans pass through.
|
||||
*
|
||||
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
|
||||
* restore all six debris chars — real newlines/tabs inside INLINE math
|
||||
* essentially never occur (options and inline math are single-line by
|
||||
* nature). Display spans restore five and KEEP real newlines — multi-line
|
||||
* display math (aligned environments, equation systems) is common, and a
|
||||
* real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
|
||||
* `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
|
||||
* legitimate content is strictly worse than failing to repair that rarest
|
||||
* slice, so display-span n-command sloppiness stays broken by design.
|
||||
*/
|
||||
const ESCAPE_DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b",
|
||||
"\t": "\\t",
|
||||
"\n": "\\n",
|
||||
"\v": "\\v",
|
||||
"\f": "\\f",
|
||||
"\r": "\\r",
|
||||
const escaped = (text: string, i: number) => {
|
||||
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||
return n % 2 === 1;
|
||||
};
|
||||
|
||||
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
||||
|
||||
/** Display spans keep real newlines (multi-line equations) — debris there is
|
||||
* everything except \n (a lone real \r never occurs in authored text, and
|
||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
||||
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
||||
|
||||
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
|
||||
export function repairMathForDisplay(text: string): string {
|
||||
return healOverescapedCommands(restoreMathEscapes(text));
|
||||
/** Preserve the legacy list-continuation policy: indented display math
|
||||
* degrades to inline rather than disrupting list structure. Literal code
|
||||
* indentation is already protected by the shared reader. */
|
||||
function indented(text: string, i: number): boolean {
|
||||
const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||
return prefix.length > 0 && /^[ \t]+$/.test(prefix);
|
||||
}
|
||||
function blockPosition(text: string, i: number): boolean {
|
||||
return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
|
||||
}
|
||||
|
||||
/** micromark closes native single-dollar spans at escaped dollars too.
|
||||
* Replace an intended literal dollar with equivalent dollar-free TeX before
|
||||
* tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
|
||||
function replaceEscapedDollars(content: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
|
||||
out += "\\text{\\textdollar}"; i++;
|
||||
} else out += content[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read old \(...\)/\[...\] syntax without touching literal content or
|
||||
* reparsing native math as Markdown. Dollar authoring remains native. */
|
||||
export function transformMathDelimiters(markdown: string): string {
|
||||
let offset = 0;
|
||||
return readContentSpans(markdown).map(span => {
|
||||
const start = offset; offset += span.raw.length;
|
||||
if (span.kind !== "math") return span.raw;
|
||||
if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
|
||||
if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
|
||||
const body = span.body.trim();
|
||||
if (!body) return span.raw;
|
||||
if (span.open === "\\(") return `$$${body}$$`;
|
||||
if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
|
||||
const before = blockPosition(markdown, start) ? "" : "\n\n";
|
||||
const end = start + span.raw.length;
|
||||
const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
|
||||
return `${before}$$\n${body}\n$$${after}`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
|
||||
};
|
||||
|
||||
/** Recover JSON-swallowed command prefixes only in recognized math, never
|
||||
* in code or genuine TeX comments. Restore all debris in one pass. Physical
|
||||
* display newlines and CRLF remain source whitespace. Inline LF recovery is
|
||||
* limited to complete known n-command names. Under the approved typical-
|
||||
* model-authoring policy this includes newline+u→nu in inline math only;
|
||||
* already-correct commands are never decoded into whitespace. */
|
||||
export function restoreMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text)) return text;
|
||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
||||
if (!/[\b\t\n\v\f\r]/.test(text)) return text;
|
||||
return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
|
||||
const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
|
||||
if (char === "\r" && part[offset + 1] === "\n") return char;
|
||||
const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
|
||||
return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
|
||||
});
|
||||
if (display) return restored;
|
||||
return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
|
||||
knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
|
||||
}));
|
||||
}
|
||||
|
||||
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
|
||||
function restoreLegacyMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text)) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!;
|
||||
if (ch === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(text, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const inner = text.slice(i + open.length, closeAt);
|
||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
||||
out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
const commandSymbol = /[,;:!|{}%]/;
|
||||
|
||||
function dollarRun(text: string, i: number): number {
|
||||
let count = 0;
|
||||
while (text[i + count] === "$") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Native math fences start at a line's block position; one-line $$…$$
|
||||
* remains inline. A flow opener may have metadata, but never another $. */
|
||||
function dollarIsDisplay(text: string, i: number, size: number): boolean {
|
||||
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
|
||||
const end = text.indexOf("\n", i + size);
|
||||
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
|
||||
}
|
||||
|
||||
/** Match micromark's delimiter runs: exact size inline, at least the
|
||||
* opener's size on an otherwise-empty line for display. Inside math,
|
||||
* backslashes are TeX data, not Markdown escapes for closing dollars. */
|
||||
function findDollarClose(
|
||||
text: string,
|
||||
from: number,
|
||||
size: number,
|
||||
isDisplay: boolean,
|
||||
): { at: number; after: number } | undefined {
|
||||
if (isDisplay) {
|
||||
let lineEnd = text.indexOf("\n", from);
|
||||
while (lineEnd !== -1) {
|
||||
const start = lineEnd + 1;
|
||||
lineEnd = text.indexOf("\n", start);
|
||||
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
|
||||
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
|
||||
if (close && close[2]!.length >= size) {
|
||||
const at = start + close[1]!.length;
|
||||
return { at, after: at + close[2]!.length };
|
||||
}
|
||||
/** Decode prefixes and row separators according to ORIGINAL TeX context.
|
||||
* A native pair is literal; an encoded pair supplies a factor, so an encoded
|
||||
* four-backslash row becomes TWO, never a command prefix. Unknown commands
|
||||
* without that structural evidence are left alone. */
|
||||
function recoverCommandRuns(inner: string, display: boolean): string {
|
||||
const ranges = texRanges(inner);
|
||||
const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
|
||||
const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
|
||||
if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
|
||||
const next = inner[offset + run.length] ?? "";
|
||||
const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
|
||||
const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
|
||||
if (encoded) {
|
||||
const factor = encoded.factor!;
|
||||
const rows = run.length % (2 * factor) === 0;
|
||||
const command = word ? knownTexCommand(word) : commandSymbol.test(next);
|
||||
if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
|
||||
return run;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
for (let j = from; j < text.length;) {
|
||||
if (text[j] !== "$") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const count = dollarRun(text, j);
|
||||
if (count === size) return { at: j, after: j + count };
|
||||
j += count;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Dollar recognition only — the same six/five-character repair maps,
|
||||
* with no over-escaping normalization or mathematical validation. */
|
||||
function restoreDollarMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
||||
i = copyFencedBlock(text, i, out);
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "`") {
|
||||
const run = backtickRun(text, i);
|
||||
let j = i + run;
|
||||
while (j < text.length) {
|
||||
const nextRun = backtickRun(text, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
const after = j < text.length ? j + run : i + run;
|
||||
out.push(text.slice(i, after));
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
// Legacy spans have already received their own inline/display policy.
|
||||
// Do not reinterpret dollars inside them in this second pass.
|
||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
|
||||
if (closeAt !== -1) {
|
||||
out.push(text.slice(i, closeAt + 2));
|
||||
i = closeAt + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
||||
const size = dollarRun(text, i);
|
||||
const isDisplay = dollarIsDisplay(text, i, size);
|
||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
||||
if (close) {
|
||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
||||
out.push(
|
||||
text.slice(i, i + size) +
|
||||
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
|
||||
text.slice(close.at, close.after),
|
||||
);
|
||||
i = close.after;
|
||||
continue;
|
||||
}
|
||||
out.push(text.slice(i, i + size));
|
||||
i += size;
|
||||
continue;
|
||||
}
|
||||
out.push(text[i]!);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
|
||||
* backslash fix.
|
||||
*
|
||||
* Some models stochastically emit DOUBLED backslashes inside JSON tool-call
|
||||
* arguments: the saved record holds `\\nabla` (two real backslashes) where
|
||||
* `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
|
||||
* the command name as plain letters — the learner sees "the word nabla, not
|
||||
* the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
|
||||
* doubled per-call, per-field (one probe's question doubled while its
|
||||
* explanation and options in the SAME call stayed clean — authoring, not
|
||||
* transport), and deepseek-v4.1-flash once; quadrupling appeared when the
|
||||
* in-session agent "compensated" for its own doubled first attempt.
|
||||
*
|
||||
* THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
|
||||
* dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
|
||||
* backslash run (2, 4, …) immediately followed by a command start — a letter
|
||||
* (any command name; bare letters after a row break are never authored
|
||||
* content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
|
||||
* norm bars) — collapses to a single backslash. Even runs before anything
|
||||
* else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
|
||||
* stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
|
||||
* backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
|
||||
* so matrix content cannot be corrupted. Outside spans nothing is touched:
|
||||
* doubled backslashes in prose or code are not ours to judge. Unbalanced
|
||||
* spans pass through, exactly like the debris passes.
|
||||
*/
|
||||
const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
|
||||
|
||||
/** Collapse even backslash runs before a command start; keep odd runs. */
|
||||
function halveOverescapedCommandRuns(inner: string): string {
|
||||
return inner.replace(
|
||||
new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
|
||||
(run: string) => (run.length % 2 === 0 ? "\\" : run),
|
||||
);
|
||||
if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
|
||||
if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
|
||||
return next && commandSymbol.test(next) ? "\\" : run;
|
||||
});
|
||||
// Structural evidence is primary (parse success cannot prove row
|
||||
// preservation). As an additional guard, never turn parseable source into
|
||||
// a parse failure through partial command recovery.
|
||||
if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export function healOverescapedCommands(text: string): string {
|
||||
if (!text.includes("\\\\")) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
||||
i = copyFencedBlock(text, i, out);
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "`") {
|
||||
const run = backtickRun(text, i);
|
||||
let j = i + run;
|
||||
while (j < text.length) {
|
||||
const nextRun = backtickRun(text, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
const after = j < text.length ? j + run : i + run;
|
||||
out.push(text.slice(i, after));
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(text, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const inner = text.slice(i + open.length, closeAt);
|
||||
out.push(open + halveOverescapedCommandRuns(inner) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
||||
const size = dollarRun(text, i);
|
||||
const isDisplay = dollarIsDisplay(text, i, size);
|
||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
||||
if (close) {
|
||||
out.push(
|
||||
text.slice(i, i + size) +
|
||||
halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
|
||||
text.slice(close.at, close.after),
|
||||
);
|
||||
i = close.after;
|
||||
continue;
|
||||
}
|
||||
out.push(text.slice(i, i + size));
|
||||
i += size;
|
||||
continue;
|
||||
}
|
||||
out.push(text[i]!);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
|
||||
}
|
||||
|
||||
/** Presentation-only, language-independent recovery. Shape parsing, saved
|
||||
* identities, grading and model history must use the unprojected source. */
|
||||
export function repairMathForDisplay(text: string): string {
|
||||
return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
|
||||
}
|
||||
|
||||
+161
-26
@@ -35,7 +35,8 @@
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import { QUESTION_TOOLS, type PendingQuestion } from "./pending.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||
import {
|
||||
CONCLUDE_ACK_TEXT,
|
||||
HANDOFF_TEXT,
|
||||
@@ -90,7 +91,26 @@ declare module "@earendil-works/pi-agent-core" {
|
||||
}
|
||||
|
||||
/** A record message: a native pi message (or our marker) plus the record id. */
|
||||
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
|
||||
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & {
|
||||
id: string;
|
||||
/** CLI-reported effective context budget, not a catalog model maximum.
|
||||
* App-only metadata: never transmitted as provider/model message content. */
|
||||
cliContextWindow?: number;
|
||||
};
|
||||
|
||||
/** Latest response only: never borrow an older process/model's window when
|
||||
* a fresh response has no metadata. Compaction invalidates the old report. */
|
||||
export function cliContextWindowOf(messages: readonly LhMessage[]): { model: string; tokens: number } | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||
if (message.role !== "assistant") continue;
|
||||
const tokens = message.cliContextWindow;
|
||||
return message.provider === CLAUDE_CODE_PROVIDER && typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0
|
||||
? { model: `${message.provider}:${message.model}`, tokens } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A record message list is assignable to the agent's context — that IS
|
||||
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
||||
@@ -98,6 +118,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[]
|
||||
return messages as unknown as AgentMessage[];
|
||||
}
|
||||
|
||||
// ─── explicit learner-facing output ─────────────────────────────────────────
|
||||
|
||||
export const SEND_TO_USER_TOOL = "send_to_user";
|
||||
|
||||
/** Read the output tool's message without changing the native call. Also
|
||||
* accepts partial arguments while streaming; malformed/empty inputs carry
|
||||
* no prose. This projection is independent of the currently selected model. */
|
||||
export function sendToUserText(block: unknown): string | undefined {
|
||||
if (!block || typeof block !== "object") return undefined;
|
||||
const call = block as { type?: unknown; name?: unknown; arguments?: unknown };
|
||||
if (call.type !== "toolCall" || call.name !== SEND_TO_USER_TOOL) return undefined;
|
||||
if (!call.arguments || typeof call.arguments !== "object") return undefined;
|
||||
const message = (call.arguments as { message?: unknown }).message;
|
||||
return typeof message === "string" && message.trim() ? message : undefined;
|
||||
}
|
||||
|
||||
/** Learner-facing prose, in content order. Only the explicit output tool
|
||||
* joins ordinary text here — thinking and all other tool inputs stay out.
|
||||
* Shared by coverage/extraction and Markdown; native history stays intact. */
|
||||
export function userFacingTextOf(content: unknown, failedOutputCalls?: ReadonlySet<string>): string[] {
|
||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||
if (!Array.isArray(content)) return [];
|
||||
const out: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (block.type === "toolCall" && failedOutputCalls?.has(block.id)) continue;
|
||||
const text = block.type === "text" ? block.text : sendToUserText(block);
|
||||
if (typeof text === "string" && text.trim()) out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Failed output calls remain retry context, not delivered teaching.
|
||||
* Truncated calls can have valid-looking partial arguments. Failed/aborted
|
||||
* generations do not execute their calls at all, so may have no result. */
|
||||
export function failedOutputCallIds(messages: readonly LhMessage[]): ReadonlySet<string> {
|
||||
const failed = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
|
||||
failed.add(message.toolCallId);
|
||||
} else if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolCall" && block.name === SEND_TO_USER_TOOL) failed.add(block.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
// ─── id minting ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** The app-wide message id shape (same as the UIMessage-era mint). */
|
||||
@@ -502,9 +571,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
|
||||
* - markers flatten to a user message carrying their content (the
|
||||
* canonical text single-sourced in core/contracts); plan blocks are
|
||||
* UI-only and filtered.
|
||||
* - dangling tool calls (an aborted turn's unanswered questions) get the
|
||||
* synthesized interrupted result — the provider protocol's hard
|
||||
* invariant: a tool call must be followed by its result.
|
||||
* - dangling calls from successfully generated assistant messages (for
|
||||
* example, an unanswered question) get a synthesized interrupted result.
|
||||
* Error/aborted generations do not: Pi drops those assistant messages,
|
||||
* so manufacturing their results would leave orphan results on the wire.
|
||||
*
|
||||
* Contract (pi-agent-core): never throws — an unknown shape passes
|
||||
* through untouched rather than killing the loop.
|
||||
@@ -513,8 +583,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
|
||||
// (the newest — chained compactions rule last) names the first kept
|
||||
// message; everything before it is replaced by the marker's content
|
||||
// (header + summary) rendered ONCE, up top. The marker itself is then
|
||||
// skipped in place — it must never render twice. A cut naming a
|
||||
// (header + summary) rendered ONCE, up top. All compaction markers
|
||||
// in the retained tail are then skipped, including older summaries
|
||||
// whose stored positions still lie after the cut. A cut naming a
|
||||
// missing id degrades honestly (everything renders — never throw).
|
||||
const lhMessages = messages as readonly LhMessage[];
|
||||
const active = activeCompactionOf(lhMessages);
|
||||
@@ -555,11 +626,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
// UI-only markers: plan blocks, and problem dismissals (the
|
||||
// synthesized conclude pair carries the model's face).
|
||||
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
|
||||
// The ACTIVE compaction marker already rendered as the head —
|
||||
// never twice. (Stale markers before the cut are unreachable; a
|
||||
// marker whose cut could not resolve has no head and renders as
|
||||
// its user text — honest degradation.)
|
||||
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
|
||||
// The head supersedes every compaction marker, not only its own
|
||||
// stored position. Without a resolved head, preserve the existing
|
||||
// full-history fallback, including the summary text.
|
||||
if (marker.customType === "compaction" && head !== null) continue;
|
||||
out.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: marker.content }],
|
||||
@@ -567,14 +637,15 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Strip the record id: the wire carries the native shape only.
|
||||
const { id: _id, ...native } = m as LhMessage;
|
||||
// Strip app-only id/CLI context metadata: the wire carries the native shape only.
|
||||
const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage;
|
||||
out.push(native as Message);
|
||||
// Dangling-call synthesis: an assistant message whose toolCalls have
|
||||
// no results in the remaining tail (abort mid-question) — the
|
||||
// provider rejects unanswered calls outright.
|
||||
// Repair unanswered calls from completed generations, not calls in
|
||||
// failed generations. Pi filters error/aborted assistants itself;
|
||||
// adding results for their unexecuted calls would leave orphans.
|
||||
if (role === "assistant") {
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
|
||||
const calls = assistant.content.filter((b) => b.type === "toolCall");
|
||||
if (calls.length === 0) continue;
|
||||
const answered = resultIdsFrom(messages.slice(i + 1));
|
||||
@@ -602,31 +673,95 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
export function danglingQuestionOf(
|
||||
messages: readonly LhMessage[],
|
||||
): { toolCallId: string; tool: string; input: unknown } | null {
|
||||
const question = questionOccurrenceOf(messages);
|
||||
return question ? { toolCallId: question.toolCallId, tool: question.tool, input: question.input } : null;
|
||||
}
|
||||
|
||||
/** The same authority, retaining native boundaries for the client dock.
|
||||
* Unique call ids are stable even when a live message's local id is replaced
|
||||
* by its persisted stamp. Only colliding ids need an occurrence-qualified
|
||||
* draft/reset key; this never changes stored ids or the answer wire. */
|
||||
export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuestion | null {
|
||||
// turn_end includes the temporary questionResult acknowledgement. The
|
||||
// view ignores it too: it is NOT the learner's answer. Exclude it only
|
||||
// from client authority, leaving persisted/server result policy intact.
|
||||
const question = questionOccurrenceOf(messages.filter(message => !isLiveQuestionPlaceholder(message)));
|
||||
if (!question) return null;
|
||||
let occurrences = 0;
|
||||
for (const message of messages) {
|
||||
// A streamed c_ assistant can temporarily coexist with its saved
|
||||
// copy during append-only adoption. New live calls are already
|
||||
// uniquified; only settled history establishes reuse.
|
||||
if (message.role !== "assistant" || message.id.startsWith("c_")) continue;
|
||||
for (const call of message.content) {
|
||||
if (call.type === "toolCall" && call.id === question.toolCallId) occurrences++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...question,
|
||||
key: occurrences > 1 ? JSON.stringify([question.messageId, question.contentIndex, question.toolCallId]) : question.toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the transient c_ result with the questionResult contract qualifies.
|
||||
* Real answers have details; errors/aborts remain resolving results. A saved
|
||||
* null result is not reinterpreted by this client-only compatibility seam. */
|
||||
function isLiveQuestionPlaceholder(message: LhMessage): boolean {
|
||||
return message.role === "toolResult" && message.id.startsWith("c_") &&
|
||||
!message.isError && message.details === null &&
|
||||
(QUESTION_TOOLS as readonly string[]).includes(message.toolName);
|
||||
}
|
||||
|
||||
/** Shared native authority: newest assistant only, siblings in call order. */
|
||||
function questionOccurrenceOf(messages: readonly LhMessage[]): Omit<PendingQuestion, "key"> | null {
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage & { id: string };
|
||||
const answered = resultIdsFrom(messages.slice(mi + 1));
|
||||
for (const block of assistant.content) {
|
||||
for (const [contentIndex, block] of assistant.content.entries()) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
|
||||
if (answered.has(block.id)) continue;
|
||||
return { toolCallId: block.id, tool: block.name, input: block.arguments };
|
||||
return {
|
||||
toolCallId: block.id, tool: block.name, input: block.arguments,
|
||||
messageId: assistant.id, contentIndex,
|
||||
};
|
||||
}
|
||||
return null; // the newest assistant message's questions are all answered
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The probe→teach construction seam (chat-handler): a probe_done call in
|
||||
* the transcript — any state — means the probe is concluded: teach. */
|
||||
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
|
||||
/** Successful probe_done result MESSAGE ids, in transcript order. A call
|
||||
* from a failed/aborted generation grants no completion authority. Its
|
||||
* later result must match both call id and tool name, must not be an
|
||||
* error, and must not explicitly refuse with details.ok:false. Older
|
||||
* clean acknowledgments without ok:true still count. No history changes. */
|
||||
export function probeDoneResultIds(messages: readonly LhMessage[]): Set<string> {
|
||||
const calls = new Set<string>();
|
||||
const results = new Set<string>();
|
||||
for (const m of messages) {
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
|
||||
if (m.role === "assistant") {
|
||||
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
|
||||
}
|
||||
} else if (
|
||||
m.role === "toolResult" && m.toolName === "probe_done" &&
|
||||
calls.has(m.toolCallId) && !m.isError &&
|
||||
(m.details as { ok?: unknown } | null | undefined)?.ok !== false
|
||||
) {
|
||||
results.add(m.id);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return results;
|
||||
}
|
||||
|
||||
/** The probe→teach construction seam (chat-handler and the tool guard):
|
||||
* only a concluded call plus a matching successful result means teach. */
|
||||
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
|
||||
return probeDoneResultIds(messages).size > 0;
|
||||
}
|
||||
|
||||
/** Every toolResult id in the record — answer-event validation (the call
|
||||
|
||||
+29
-7
@@ -38,10 +38,18 @@ export interface PendingToolCall {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
/** Client-only identity: projection may group several native assistants.
|
||||
* Never persisted or sent as part of an answer event. */
|
||||
export interface PendingQuestion extends PendingToolCall {
|
||||
messageId: string;
|
||||
contentIndex: number;
|
||||
/** Unique call ids stay stable across live → persisted message stamps.
|
||||
* Reused ids include their native occurrence instead. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Tools that render in the question dock and gate the composer. The
|
||||
* probe-phase names (probe/recall, owner 2026-10-09) join their teach
|
||||
* twins — old records keep the teach names; new probe-phase calls carry
|
||||
* the phase names. */
|
||||
* probe-phase names join their teach twins; old records keep teach names. */
|
||||
export const QUESTION_TOOLS = [
|
||||
"quiz",
|
||||
"probe",
|
||||
@@ -105,14 +113,20 @@ export function filterEnabledTools<T>(tools: Record<string, T>, enabled?: Enable
|
||||
return out;
|
||||
}
|
||||
|
||||
function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null {
|
||||
function findPending(messages: UIMessage[], names: readonly string[], authority?: string | PendingQuestion): PendingToolCall | null {
|
||||
for (let m = messages.length - 1; m >= 0; m--) {
|
||||
const msg = messages[m]!;
|
||||
for (let p = 0; p < msg.parts.length; p++) {
|
||||
const part = msg.parts[p]!;
|
||||
if (isToolUIPart(part) && part.state === "input-available") {
|
||||
const tool = part.type.slice("tool-".length);
|
||||
if (names.includes(tool)) {
|
||||
if (!names.includes(tool)) continue;
|
||||
if (typeof authority === "object") {
|
||||
if (part.toolCallId !== authority.toolCallId || tool !== authority.tool ||
|
||||
part.sourceMessageId !== authority.messageId || part.sourceContentIndex !== authority.contentIndex) continue;
|
||||
return authority;
|
||||
}
|
||||
if (authority === undefined || part.toolCallId === authority) {
|
||||
return { toolCallId: part.toolCallId as string, tool, input: part.input };
|
||||
}
|
||||
}
|
||||
@@ -121,8 +135,16 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
||||
return findPending(messages, QUESTION_TOOLS);
|
||||
/** Grouped view messages lose native assistant boundaries. Native callers
|
||||
* supply the exact occurrence, not just an id: its OWN part must have cleared
|
||||
* validation. Never fall back to an older available question.
|
||||
* null means no native pending question; undefined keeps view-only callers.
|
||||
* The string overload is retained for view-only/id-filter consumers. */
|
||||
export function findPendingToolCall(messages: UIMessage[], authority: PendingQuestion | null): PendingQuestion | null;
|
||||
export function findPendingToolCall(messages: UIMessage[], authority?: string | null): PendingToolCall | null;
|
||||
export function findPendingToolCall(messages: UIMessage[], authority?: string | PendingQuestion | null): PendingToolCall | null {
|
||||
if (authority === null) return null;
|
||||
return findPending(messages, QUESTION_TOOLS, authority);
|
||||
}
|
||||
|
||||
// ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ──────
|
||||
|
||||
+8
-11
@@ -33,7 +33,7 @@
|
||||
* Both are strictly sequential; the two derivations agree on every settled
|
||||
* record (a call's part projects into the same view boundary).
|
||||
*
|
||||
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
||||
* Pure: only browser-safe core helpers — safe on server, client, and tests.
|
||||
*/
|
||||
|
||||
/** The three phases of a session, as the picker / header / dividers see them. */
|
||||
@@ -80,7 +80,10 @@ function advance(
|
||||
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
|
||||
// not its answer — an errored-and-retried call still proves teaching
|
||||
// began, and the retry lands in the same group anyway).
|
||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||
if (
|
||||
phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" &&
|
||||
(part.output as { ok?: unknown } | null | undefined)?.ok !== false
|
||||
) {
|
||||
phase = "plan";
|
||||
}
|
||||
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
|
||||
@@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
||||
|
||||
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
||||
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
import { probeDoneResultIds, type LhMessage } from "./native-messages.ts";
|
||||
|
||||
/** The phase at `upToIndex` (inclusive; default: the last message) of a
|
||||
* NATIVE record. probe→plan advances at the probe_done call's clean
|
||||
@@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts";
|
||||
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
|
||||
let phase: Phase3 = "probe";
|
||||
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||
const concluded = probeDoneResultIds(messages.slice(0, last + 1));
|
||||
for (let i = 0; i <= last; i++) {
|
||||
const m = messages[i]!;
|
||||
const role = (m as { role?: string }).role;
|
||||
if (role === "toolResult") {
|
||||
const result = m as { toolName?: string; isError?: boolean };
|
||||
if (
|
||||
phase === "probe" &&
|
||||
result.toolName === "probe_done" &&
|
||||
!result.isError
|
||||
) {
|
||||
phase = "plan";
|
||||
}
|
||||
if (phase === "probe" && concluded.has(m.id)) phase = "plan";
|
||||
} else if (role === "assistant") {
|
||||
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
|
||||
if (phase === "plan") {
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface CreateJobInit {
|
||||
toolCallId: string;
|
||||
/** First activity line (defaults to "starting"). */
|
||||
activity?: string;
|
||||
/** Saved on the job before its worker is spawned. */
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
/** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */
|
||||
|
||||
@@ -511,6 +511,21 @@ export interface DockSelection {
|
||||
|
||||
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
|
||||
|
||||
/** In-memory submission snapshot: survives the question form's optimistic
|
||||
* unmount, never written to storage. Indices use the call's stable shuffle. */
|
||||
export interface QuestionFormDraft {
|
||||
selection: DockSelection;
|
||||
thinking: string;
|
||||
other: string;
|
||||
text: string;
|
||||
confidenceIndex: number;
|
||||
}
|
||||
|
||||
export interface QuestionDraft {
|
||||
form?: QuestionFormDraft;
|
||||
readingThinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggling a listed option. Single-select: the option becomes THE answer and
|
||||
* displaces "Other". Multi-select: the option toggles alongside its siblings
|
||||
|
||||
@@ -23,22 +23,15 @@ import { translator, type Language } from "./i18n.ts";
|
||||
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
import {
|
||||
failedOutputCallIds,
|
||||
PRACTICE_CONCLUDE_TOOL,
|
||||
PRACTICE_PROBLEM_TOOL,
|
||||
problemInputForConclude,
|
||||
SEND_TO_USER_TOOL,
|
||||
userFacingTextOf,
|
||||
} from "./native-messages.ts";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
/** The user-facing text blocks of a native message (string or text parts). */
|
||||
function textOf(content: unknown): string[] {
|
||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
||||
.map((c) => c.text)
|
||||
.filter((t) => t.trim().length > 0);
|
||||
}
|
||||
|
||||
/** The practice join context (owner-approved, 2026-10-06 export round):
|
||||
* practice tool RESULTS are acks — the statement, outcome, and verdict
|
||||
* all live in the assistant's call arguments, and the model solution
|
||||
@@ -49,6 +42,7 @@ export interface MarkdownContext {
|
||||
messages: ReadonlyArray<LhMessage>;
|
||||
/** toolCallId → the call part's name + arguments. */
|
||||
calls: Map<string, { name: string; args: Record<string, unknown> | undefined }>;
|
||||
failedOutputCalls?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
|
||||
@@ -67,7 +61,7 @@ function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { messages, calls };
|
||||
return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) };
|
||||
}
|
||||
|
||||
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
|
||||
@@ -176,16 +170,16 @@ export function messageMarkdown(
|
||||
const parts: string[] = [];
|
||||
|
||||
if (role === "user") {
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
for (const text of userFacingTextOf((message as { content: unknown }).content)) {
|
||||
parts.push(`### ${t("Learner")}${anchor}\n\n${text.trim()}`);
|
||||
}
|
||||
} else if (role === "assistant") {
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
for (const text of userFacingTextOf((message as { content: unknown }).content, ctx?.failedOutputCalls ?? failedOutputCallIds([message]))) {
|
||||
parts.push(`### ${t("Teacher")}${anchor}\n\n${text.trim()}`);
|
||||
}
|
||||
} else if (role === "toolResult") {
|
||||
const result = message as ToolResultMessage;
|
||||
if (result.isError) return null; // retry context, not learning evidence
|
||||
if (result.isError || result.toolName === SEND_TO_USER_TOOL) return null; // retry context or output ack, not learning evidence
|
||||
const { heading, body } = toolResultBody(result, ctx, language);
|
||||
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
type BackgroundModelRole,
|
||||
type FactorySettings,
|
||||
type GeneralSettings,
|
||||
type DictationSettings,
|
||||
type CompactionSettings,
|
||||
type CustomModel,
|
||||
type CustomModelOverride,
|
||||
type GraphSettings,
|
||||
type ModelSlot,
|
||||
type ModelSlotName,
|
||||
@@ -71,6 +74,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
|
||||
/** The concrete view: every knob resolved, no absent fields. */
|
||||
export interface EffectiveSettings {
|
||||
general: GeneralSettings & { autoUpdate: boolean };
|
||||
dictation: Required<DictationSettings>;
|
||||
agent: {
|
||||
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
|
||||
/** Delta map as stored — consumers resolve absent key → enabled. */
|
||||
@@ -95,6 +99,9 @@ export interface EffectiveSettings {
|
||||
/** Compaction (owner round 14): resolved concrete — absent record
|
||||
* section resolves to the factory 150k. */
|
||||
compaction: Required<CompactionSettings>;
|
||||
/** Custom endpoint, resolved concrete: baseUrl defaults "", models and
|
||||
* user pins default to empty. */
|
||||
customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record<string, CustomModelOverride>; manualModels: string[] };
|
||||
}
|
||||
|
||||
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
|
||||
@@ -187,6 +194,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
...(record?.general?.sttModel ? { sttModel: record.general.sttModel } : {}),
|
||||
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
|
||||
},
|
||||
dictation: {
|
||||
language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
|
||||
},
|
||||
agent: {
|
||||
models,
|
||||
tools: agent.tools ?? {},
|
||||
@@ -227,6 +237,12 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
compaction: {
|
||||
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
|
||||
},
|
||||
customProvider: {
|
||||
baseUrl: record?.customProvider?.baseUrl ?? "",
|
||||
models: record?.customProvider?.models ?? [],
|
||||
overrides: record?.customProvider?.overrides ?? {},
|
||||
manualModels: record?.customProvider?.manualModels ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+200
-3
@@ -29,11 +29,13 @@
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
import { isLanguage, isSttLanguage, type Language, type SttLanguage } from "./i18n.ts";
|
||||
import { isSttModel, type SttModel } from "./stt.ts";
|
||||
import { isDictationLanguage, type DictationLanguage } from "./dictation.ts";
|
||||
|
||||
/** The schema version this code understands. Bump ONLY for breaking shape
|
||||
* changes (a field renamed/meaning changed); pure additions never bump. */
|
||||
@@ -157,10 +159,11 @@ export interface ProviderKeyMissing {
|
||||
/** The refusal wording's invariant tail — the matcher below keys on it
|
||||
* (the label prefix varies by provider). */
|
||||
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
|
||||
const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers.";
|
||||
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
|
||||
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
||||
return {
|
||||
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
|
||||
error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
|
||||
code: PROVIDER_KEY_MISSING_CODE,
|
||||
provider,
|
||||
slots,
|
||||
@@ -171,7 +174,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr
|
||||
* scary-error) — the envelope's home module owns the string so the
|
||||
* writer and the matcher can never drift. */
|
||||
export function isProviderKeyRefusal(error: string | undefined): boolean {
|
||||
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
|
||||
return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE);
|
||||
}
|
||||
/** The typed throw for in-process seams (extraction pipeline) — the
|
||||
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
||||
@@ -209,6 +212,98 @@ export interface ModelsSettings {
|
||||
vision?: ModelSlot;
|
||||
}
|
||||
|
||||
// ─── Custom OpenAI-compatible endpoint ───────────────────────────────────────
|
||||
// ONE user-configured OpenAI-compatible server (vLLM / Ollama / a proxy). It is
|
||||
// one more provider in the Keys list, keyed by a base URL instead of an env var.
|
||||
// NON-SECRET config only (base URL + discovered model ids); the API key is a
|
||||
// secret in the auth store, never here. Registered into pi-ai as a normal
|
||||
// provider (server/custom-provider.ts).
|
||||
|
||||
/** Budget fallbacks only; custom endpoint pricing is unknown. */
|
||||
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/** Custom budgets accept every positive integer, including small output caps. */
|
||||
export const CUSTOM_MODEL_PIN_FLOOR = 1;
|
||||
|
||||
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
|
||||
* detected from the endpoint's /models metadata; anything it omits falls back
|
||||
* to a default. */
|
||||
export interface CustomModel {
|
||||
id: string;
|
||||
/** Accepts image input (Vision-capable). Default false (text-only). */
|
||||
image?: boolean;
|
||||
/** Accepts a reasoning effort (thinking). Default false → the only
|
||||
* thinking level is "off", exactly like a non-reasoning built-in. */
|
||||
reasoning?: boolean;
|
||||
/** Context window in tokens, from the endpoint. Absent → the default. */
|
||||
context?: number;
|
||||
/** Max output tokens, from the endpoint. Absent → the default. */
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** A user pin for one custom model: it beats what the endpoint publishes
|
||||
* (owner ruling — explicit user intent wins; a local server's number is
|
||||
* often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
|
||||
* true declares reasoning support, false removes that declaration, absent
|
||||
* uses discovery. This is NOT a backend execution/disable switch. */
|
||||
export interface CustomModelOverride {
|
||||
context?: number;
|
||||
maxTokens?: number;
|
||||
reasoning?: boolean;
|
||||
}
|
||||
|
||||
/** The single custom endpoint's stored config (delta section). */
|
||||
export interface CustomProviderSettings {
|
||||
/** Server-owned random authorization nonce; never a credential hash. */
|
||||
credentialBindingId?: string;
|
||||
/** Explicit authentication intent; never inferred from a saved key. */
|
||||
authMode?: "keyless" | "apiKey";
|
||||
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
|
||||
baseUrl?: string;
|
||||
/** Models this endpoint offers: advertised ids merged with the typed
|
||||
* manual ones (rewritten on connect; a failed probe keeps the same
|
||||
* endpoint's last snapshot). */
|
||||
models?: CustomModel[];
|
||||
/** User pins keyed by model id — pure user intent, never rewritten by
|
||||
* discovery, pruned on connect to the ids that connect offers. */
|
||||
overrides?: Record<string, CustomModelOverride>;
|
||||
/** Model ids the user typed by hand, for an endpoint that publishes no
|
||||
* usable /models list. Kept apart from the snapshot so a reconnect can
|
||||
* re-merge them after discovery refreshes. */
|
||||
manualModels?: string[];
|
||||
}
|
||||
|
||||
export function parseModelIds(raw: unknown): string[] {
|
||||
const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
|
||||
const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/** Canonical base-URL form (single source): trims, requires absolute http(s),
|
||||
* drops trailing slashes. Returns "" for anything invalid ("not configured"). */
|
||||
export function normalizeBaseUrl(raw: string): string {
|
||||
const value = raw.trim();
|
||||
if (!value) return "";
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
if (url.username || url.password || url.search || url.hash || value.includes("?") || value.includes("#")) return "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** THE single source of "is the custom endpoint usable?": a non-empty base URL.
|
||||
* Key presence is deliberately excluded (keyless local servers are allowed). */
|
||||
export function customProviderUsable(
|
||||
cp: CustomProviderSettings | undefined,
|
||||
): cp is CustomProviderSettings & { baseUrl: string } {
|
||||
return Boolean(cp?.baseUrl);
|
||||
}
|
||||
|
||||
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
|
||||
* to start chats until the user has actively picked all three slots —
|
||||
* there ARE no defaults for 33 providers; a configured app is one whose
|
||||
@@ -235,7 +330,7 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
|
||||
/** General settings — app-level behavior. Version DISPLAY and plans login
|
||||
* live outside the record (package.json / auth.json) by owner decision. */
|
||||
export interface GeneralSettings {
|
||||
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
|
||||
/** Automatic startup/24h update checks (default ON). Manual checks bypass this. */
|
||||
autoUpdate?: boolean;
|
||||
/** Absent = browser/system language (English for unsupported languages). */
|
||||
language?: Language;
|
||||
@@ -247,6 +342,11 @@ export interface GeneralSettings {
|
||||
sttPreferredLanguages?: Language[];
|
||||
}
|
||||
|
||||
/** One app-wide dictation preference, shared by every composer. */
|
||||
export interface DictationSettings {
|
||||
language?: DictationLanguage;
|
||||
}
|
||||
|
||||
/** Agent settings — everything about the non-conversation machinery. */
|
||||
export interface AgentSettings {
|
||||
/** Background-role model/thinking overrides (absent role = config pin). */
|
||||
@@ -373,9 +473,14 @@ export interface CompactionSettings {
|
||||
export interface UserSettings {
|
||||
version: number;
|
||||
general?: GeneralSettings;
|
||||
dictation?: DictationSettings;
|
||||
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
|
||||
claudeCode?: { enabled?: boolean };
|
||||
agent?: AgentSettings;
|
||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||
models?: ModelsSettings;
|
||||
/** The custom OpenAI-compatible endpoint (non-secret config only). */
|
||||
customProvider?: CustomProviderSettings;
|
||||
appearance?: AppearanceSettings;
|
||||
graph?: GraphSettings;
|
||||
compaction?: CompactionSettings;
|
||||
@@ -390,6 +495,7 @@ export interface UserSettings {
|
||||
export interface FactorySettings {
|
||||
version: number;
|
||||
general: GeneralSettings & { autoUpdate: boolean };
|
||||
dictation: Required<DictationSettings>;
|
||||
appearance: {
|
||||
mode: "dark" | "light";
|
||||
background: string | null;
|
||||
@@ -409,6 +515,7 @@ export interface FactorySettings {
|
||||
export const FACTORY_SETTINGS: FactorySettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
general: { autoUpdate: true },
|
||||
dictation: { language: "en" },
|
||||
appearance: {
|
||||
mode: "dark",
|
||||
background: null, // the dark default (#141414) lives in the mode's derivation curve
|
||||
@@ -510,6 +617,80 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A positive integer, or undefined — keeps only meaningful token counts. */
|
||||
function positiveInt(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** One pin, clamped up to the floor (clamp-never-reject on read). */
|
||||
function sanitizeCustomModelOverride(raw: unknown): CustomModelOverride | undefined {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const out: CustomModelOverride = {};
|
||||
const context = positiveInt(rec.context);
|
||||
if (context) out.context = Math.max(CUSTOM_MODEL_PIN_FLOOR, context);
|
||||
const maxTokens = positiveInt(rec.maxTokens);
|
||||
if (maxTokens) out.maxTokens = Math.max(CUSTOM_MODEL_PIN_FLOOR, maxTokens);
|
||||
if (typeof rec.reasoning === "boolean") out.reasoning = rec.reasoning;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
/** THE pins map + THE prune law: well-formed entries only, ids trimmed, and
|
||||
* when `liveIds` is given every entry for a model the endpoint no longer
|
||||
* advertises drops. POST passes the discovered ids, PATCH the stored list. */
|
||||
export function sanitizeCustomModelOverrides(
|
||||
raw: unknown,
|
||||
liveIds?: Iterable<string>,
|
||||
): Record<string, CustomModelOverride> {
|
||||
const out: Record<string, CustomModelOverride> = {};
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||
const live = liveIds ? new Set(liveIds) : undefined;
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const id = key.trim();
|
||||
if (!id || (live && !live.has(id))) continue;
|
||||
const pin = sanitizeCustomModelOverride(value);
|
||||
if (pin) Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The customProvider section: normalized base URL (invalid → dropped) and
|
||||
* discovered models (ids trimmed, dupes/empties dropped; image kept only when
|
||||
* true; token counts only as positive ints). Clamp-never-reject. */
|
||||
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
|
||||
const out: CustomProviderSettings = {};
|
||||
if (typeof raw.credentialBindingId === "string" && /^[a-f0-9]{32}$/.test(raw.credentialBindingId)) out.credentialBindingId = raw.credentialBindingId;
|
||||
if (raw.authMode === "keyless" || raw.authMode === "apiKey") out.authMode = raw.authMode;
|
||||
if (typeof raw.baseUrl === "string") {
|
||||
const base = normalizeBaseUrl(raw.baseUrl);
|
||||
if (base) out.baseUrl = base;
|
||||
}
|
||||
if (Array.isArray(raw.models)) {
|
||||
const byId = new Map<string, CustomModel>();
|
||||
for (const m of raw.models) {
|
||||
if (typeof m !== "object" || m === null) continue;
|
||||
const rec = m as Record<string, unknown>;
|
||||
if (typeof rec.id !== "string") continue;
|
||||
const id = rec.id.trim();
|
||||
if (!id) continue;
|
||||
const model: CustomModel = { id };
|
||||
if (rec.image === true) model.image = true;
|
||||
if (rec.reasoning === true) model.reasoning = true;
|
||||
const context = positiveInt(rec.context);
|
||||
if (context) model.context = context;
|
||||
const maxTokens = positiveInt(rec.maxTokens);
|
||||
if (maxTokens) model.maxTokens = maxTokens;
|
||||
byId.set(id, model);
|
||||
}
|
||||
out.models = [...byId.values()];
|
||||
}
|
||||
const overrides = sanitizeCustomModelOverrides(raw.overrides);
|
||||
if (Object.keys(overrides).length) out.overrides = overrides;
|
||||
const manualModels = parseModelIds(raw.manualModels);
|
||||
if (manualModels.length) out.manualModels = manualModels;
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const out: Record<string, boolean> = {};
|
||||
@@ -637,6 +818,19 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
||||
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
|
||||
out.general = sanitizeGeneral(r.general as Record<string, unknown>);
|
||||
}
|
||||
if (r.dictation !== undefined) {
|
||||
const raw = r.dictation;
|
||||
out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) &&
|
||||
isDictationLanguage((raw as Record<string, unknown>).language)
|
||||
? { language: (raw as DictationSettings).language }
|
||||
: {};
|
||||
}
|
||||
if (r.claudeCode !== undefined) {
|
||||
const raw = r.claudeCode;
|
||||
out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw)
|
||||
&& typeof (raw as Record<string, unknown>).enabled === "boolean"
|
||||
? { enabled: (raw as { enabled: boolean }).enabled } : {};
|
||||
}
|
||||
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||
}
|
||||
@@ -647,6 +841,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
||||
const models = sanitizeModels(r.models as Record<string, unknown>);
|
||||
if (Object.keys(models).length) out.models = models;
|
||||
}
|
||||
if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) {
|
||||
out.customProvider = sanitizeCustomProvider(r.customProvider as Record<string, unknown>);
|
||||
}
|
||||
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
|
||||
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
|
||||
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
|
||||
|
||||
+6
-26
@@ -30,7 +30,6 @@
|
||||
import { Value } from "typebox/value";
|
||||
import type { TSchema } from "typebox";
|
||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||
import { repairMathForDisplay } from "./math-delims.ts";
|
||||
import { healEncodedArguments } from "./argument-heal.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||
|
||||
@@ -67,28 +66,9 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
||||
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
||||
* widens to "") renders as a quiet fallback, not a broken quiz.
|
||||
*/
|
||||
/** LaTeX-escape restoration, deep over every string field (the JSON-debris
|
||||
* fix, owner ruling 2026-09-26): records persist RAW call args, so
|
||||
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
|
||||
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
|
||||
* display path. This is the ONE seam every rendered question passes
|
||||
* through — new turns and already-poisoned records alike heal here.
|
||||
* healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
|
||||
* the same models also stochastically DOUBLE command backslashes
|
||||
* (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
|
||||
* plus "nabla" as letters), so even runs before a command start inside
|
||||
* balanced math spans collapse to one backslash. */
|
||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||
if (typeof value === "string") return repairMathForDisplay(value);
|
||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Shape parsing never repairs authored text. The dock builds its answer
|
||||
* data from this source copy; recovery belongs exclusively to presentation
|
||||
* (ToolContentResponse), never option identities, grading, or snapshots. */
|
||||
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
|
||||
// OPTIONAL `type` — new teach-phase records carry predict|check and the
|
||||
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
// wobble must heal HERE too or a posed quiz renders invisible (the
|
||||
// tool-parts law). healEncodedArguments is the same pure pass the
|
||||
// boundary's withArgumentHeal runs.
|
||||
const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
|
||||
const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
|
||||
if (!cleaned) return undefined;
|
||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||
@@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
* keying, so they parse to undefined exactly like the zod seam did).
|
||||
*/
|
||||
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
|
||||
if (!cleaned) return undefined;
|
||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||
return cleaned;
|
||||
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||
* variants share the same fields; the teacher's nodeId is an unknown
|
||||
* key Value.Clean strips, same as the quiz seam. */
|
||||
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
|
||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
|
||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, input);
|
||||
if (!cleaned) return undefined;
|
||||
if (explainInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
+35
-2
@@ -34,7 +34,7 @@
|
||||
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts";
|
||||
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude, SEND_TO_USER_TOOL, sendToUserText } from "./native-messages.ts";
|
||||
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||
@@ -52,6 +52,9 @@ export interface LhViewPart extends Record<string, unknown> {
|
||||
text?: string;
|
||||
state?: string;
|
||||
toolCallId?: string;
|
||||
/** Native location, retained across display grouping (client-only). */
|
||||
sourceMessageId?: string;
|
||||
sourceContentIndex?: number;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
rawInput?: unknown;
|
||||
@@ -124,6 +127,14 @@ function lastSuccessfulPlanInit(parts: LhViewPart[]): number {
|
||||
* view messages. Pure: the same input always projects to the same list. */
|
||||
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
|
||||
const out: LhViewMessage[] = [];
|
||||
// Output calls become text rather than tool parts, so read settled
|
||||
// failures up front: a failed call must retain the normal error path.
|
||||
const outputErrors = new Map<string, ToolResultMessage>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
|
||||
outputErrors.set(message.toolCallId, message);
|
||||
}
|
||||
}
|
||||
|
||||
const lastAssistant = (): LhViewMessage | undefined => {
|
||||
const last = out[out.length - 1];
|
||||
@@ -211,13 +222,35 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
||||
// back to a spinner mid-transcript; live cards belong at the
|
||||
// bottom, and this gate is what puts them there by construction).
|
||||
const streamingMessage = live?.streamingIds.has(m.id) === true;
|
||||
for (const block of assistant.content) {
|
||||
for (const [contentIndex, block] of assistant.content.entries()) {
|
||||
if (block.type === "text") {
|
||||
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
|
||||
} else if (block.type === "thinking") {
|
||||
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
||||
} else if (block.type === "toolCall") {
|
||||
const part = toolPartOf(block, live, streamingMessage);
|
||||
if ((QUESTION_TOOLS as readonly string[]).includes(block.name)) {
|
||||
part.sourceMessageId = m.id;
|
||||
part.sourceContentIndex = contentIndex;
|
||||
}
|
||||
if (block.name === SEND_TO_USER_TOOL) {
|
||||
// Failed/aborted generations never execute their calls;
|
||||
// the provider error/abort UI already owns that outcome.
|
||||
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
|
||||
const error = outputErrors.get(block.id);
|
||||
if (error) applyResult(part, error);
|
||||
if (part.state === "output-error") {
|
||||
rest.push(part);
|
||||
} else {
|
||||
const text = sendToUserText(block);
|
||||
if (text !== undefined) {
|
||||
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text, state: "done" });
|
||||
}
|
||||
}
|
||||
// No live tool card or acknowledgement: this channel is
|
||||
// ordinary prose, including while its arguments stream.
|
||||
continue;
|
||||
}
|
||||
// The conclusion's reveal join (owner round, 2026-10-05): the
|
||||
// hero of a conclude card is the modelSolution committed at POSE
|
||||
// time — a record fold, enriched here so the card renders pure.
|
||||
|
||||
+92
-23
@@ -9,7 +9,7 @@ import {
|
||||
type TeachingAgent,
|
||||
} from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
|
||||
import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
|
||||
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
|
||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
|
||||
@@ -19,7 +19,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import {
|
||||
deliverJobResult,
|
||||
noteSteerVisible,
|
||||
noteSteersDurable,
|
||||
reconcileUserSteers,
|
||||
registerLiveAgent,
|
||||
restoreUncommittedAutomatic,
|
||||
@@ -35,6 +35,7 @@ import { providerEventNote } from "../agent/provider-events.ts";
|
||||
import { effectiveSettings, stores } from "./stores.ts";
|
||||
import { searchAvailable, searchProvider } from "./search.ts";
|
||||
import { providerKeyPresent } from "./keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import {
|
||||
extractionEnabled,
|
||||
graphToolsEnabled,
|
||||
@@ -44,8 +45,8 @@ import {
|
||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { applyParkedDismissal } from "./dismissal-park.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||
@@ -57,10 +58,11 @@ import {
|
||||
ChatInputError,
|
||||
inputAppends,
|
||||
parseChatInput,
|
||||
retryDrops,
|
||||
type TurnInput,
|
||||
} from "../core/input-events.ts";
|
||||
import { steerDelivered } from "../core/contracts.ts";
|
||||
import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
||||
import type {CallUsage} from "../core/contracts.ts";
|
||||
|
||||
/**
|
||||
* One chat turn over the server-owned native record. Framework-free (plain
|
||||
@@ -81,6 +83,16 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
||||
* for write-ahead persistence, turn liveness, and the turn services.
|
||||
*/
|
||||
|
||||
/** Fresh check only at new background-run admission; never cancel a run
|
||||
* already in progress when the opt-in is switched off. */
|
||||
function connectionBackgroundAllowed(provider: string): boolean {
|
||||
return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider));
|
||||
}
|
||||
|
||||
function settlementErrorText(error: unknown): string {
|
||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||
}
|
||||
|
||||
const jobRunner = createJobRunner({
|
||||
languageForChat: chatLanguage,
|
||||
chatStore: stores.chatStore,
|
||||
@@ -187,6 +199,17 @@ export async function handleChatInput(
|
||||
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Refuse an ineligible Retry before catalog refresh, compaction, or any
|
||||
// turn/write effects. Revalidate after the awaited gates below too.
|
||||
if (input.type === "retry") {
|
||||
try {
|
||||
retryDrops(record.messages);
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
|
||||
// CONVERSATION chain — the chat's own model override over the strong
|
||||
// slot — and there is no factory floor; when both are empty the turn
|
||||
@@ -261,7 +284,20 @@ export async function handleChatInput(
|
||||
chatId,
|
||||
);
|
||||
const compactedRecord = stores.chatStore.getChat(chatId);
|
||||
if (compactedRecord) record = compactedRecord;
|
||||
if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
record = compactedRecord;
|
||||
// Recompute Retry eligibility on current truth, still before beginTurn.
|
||||
// A compaction marker or another writer may have landed during awaits.
|
||||
// These omissions affect ONLY the agent context, never the saved record.
|
||||
let retryOmissions = new Set<string>();
|
||||
if (input.type === "retry") {
|
||||
try {
|
||||
retryOmissions = retryDrops(record.messages);
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// The input's native appends (pure; the honest 400 for an answer with no
|
||||
// pending question call). Computed BEFORE beginTurn so a refused input
|
||||
@@ -295,7 +331,11 @@ export async function handleChatInput(
|
||||
// the composer gates), so adjacency holds by construction.
|
||||
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
||||
const merged = [...record.messages, ...appends];
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
try { stores.chatStore.saveMessages(chatId, merged); }
|
||||
catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||
}
|
||||
emit("record-changed", { chatId });
|
||||
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
||||
}
|
||||
@@ -315,19 +355,27 @@ export async function handleChatInput(
|
||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||
// is the ONLY copy ever appended.)
|
||||
const merged = [...record.messages, ...appends];
|
||||
// Retry preserves every persisted attempt. Pi needs a non-assistant
|
||||
// tail to start continue(), so its temporary history omits the trailing
|
||||
// failures only. Count that SAME temporary prefix at settlement, or the
|
||||
// retained failures would make persistTurn skip genuinely new output.
|
||||
const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged;
|
||||
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length;
|
||||
try {
|
||||
stores.chatStore.saveLanguage?.(chatId, language);
|
||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
||||
// in the record — flip their jobs now (the wake's steers ride this
|
||||
// write-ahead; the delivering turn may run for minutes).
|
||||
for (const appended of stagedAutomatic ? [] : appends) {
|
||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
endTurn(chatId);
|
||||
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||
}
|
||||
// Visibility bookkeeping is not the canonical save. Its failure must
|
||||
// neither misreport a saved input as lost nor prevent the turn running.
|
||||
try {
|
||||
if (!stagedAutomatic) noteSteersDurable(chatId, merged);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
|
||||
}
|
||||
|
||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||
@@ -353,7 +401,7 @@ export async function handleChatInput(
|
||||
// the session is still probing: build the PROBER (quiz/ask + probe_done).
|
||||
// Both share this whole composition (write-ahead persist, liveness,
|
||||
// steers, usage, extraction) — only the agent differs.
|
||||
const teaching = hasProbeDone(merged);
|
||||
const teaching = hasProbeDone(agentMessages);
|
||||
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
|
||||
// turn gate above (chat.model override → strong slot → refused) — the
|
||||
// engine build is pure from here; thinking levels clamp natively.
|
||||
@@ -372,12 +420,16 @@ export async function handleChatInput(
|
||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||
if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({
|
||||
initialMessages: asAgentMessages(merged),
|
||||
initialMessages: asAgentMessages(agentMessages),
|
||||
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||
foregroundOwed: !callPurpose.background,
|
||||
purpose: callPurpose,
|
||||
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
|
||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||
commitStaged: () => {
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
persistBaseCount = merged.length;
|
||||
noteSteersDurable(chatId, merged);
|
||||
},
|
||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||
});
|
||||
const agentOpts: AgentBuildOptions = {
|
||||
@@ -385,7 +437,7 @@ export async function handleChatInput(
|
||||
engine,
|
||||
callPurpose,
|
||||
requestCycle,
|
||||
messages: asAgentMessages(merged),
|
||||
messages: asAgentMessages(agentMessages),
|
||||
onUsage: collectUsage,
|
||||
toolToggles: effective.agent.tools,
|
||||
// Debug (owner, 2026-10-04): teacher/prober raw provider events under
|
||||
@@ -476,11 +528,12 @@ export async function handleChatInput(
|
||||
// run's first LLM call (the native replacement for continuation
|
||||
// coalescing). Settlement still cannot beat the flow below: it needs
|
||||
// a streamed response, which never happens inside the constructor.
|
||||
registerLiveAgent(chatId, constructed);
|
||||
registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue",
|
||||
input.type === "steers" ? new Set(appends.map((message) => message.id)) : undefined);
|
||||
// Debug recorder run marker (owner, 2026-10-04): correlates the
|
||||
// JSONL event stream with the record's turns; no-op unless
|
||||
// LH_PROVIDER_EVENTS=1.
|
||||
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
|
||||
providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`);
|
||||
const handle = runPiTurn({
|
||||
agent: constructed,
|
||||
requestCycle,
|
||||
@@ -504,10 +557,26 @@ export async function handleChatInput(
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
onSettleError: settlementErrorText,
|
||||
onSettleFailure: (error, reportedErrorText) => {
|
||||
// POST only acknowledges acceptance; the submitter may attach
|
||||
// after this turn has already failed and left the registry.
|
||||
rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
|
||||
endTurn(chatId);
|
||||
unregisterTurn(chatId);
|
||||
unregisterLiveAgent(chatId);
|
||||
// The canonical save may have succeeded before later bookkeeping
|
||||
// threw. Reconcile against actual durability, never replay input
|
||||
// merely because usage/extraction/other settlement work failed.
|
||||
const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
|
||||
reconcileUserSteers(chatId, persisted);
|
||||
settleSteers(chatId, persisted, true, constructed);
|
||||
},
|
||||
onFinish: (turnMessages, isAborted) => {
|
||||
// Wave B: the record write is the agent's own final state —
|
||||
// the new messages are the slice past the merged base.
|
||||
// Wave B: append only messages past the temporary agent prefix.
|
||||
// Retry's saved record still includes its earlier failed attempts.
|
||||
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
||||
noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped);
|
||||
// usage log append (idempotent per turn id) — the stats bar
|
||||
// refolds this on the client after the turn settles. The
|
||||
// provenance message is the turn's FIRST new assistant message.
|
||||
@@ -553,7 +622,7 @@ export async function handleChatInput(
|
||||
// resolve; stranded markers return to pending), then let
|
||||
// the pump take any stragglers down the idle wake.
|
||||
unregisterLiveAgent(chatId);
|
||||
settleSteers(chatId, stamped);
|
||||
settleSteers(chatId, stamped, false, constructed);
|
||||
// Composer steers (2026-10-03): delivered ids dropped, stranded
|
||||
// ones stay parked for the chat's next run (the registry is the
|
||||
// stranding safety net — the agent's undrained copy died above).
|
||||
|
||||
+27
-6
@@ -1,10 +1,13 @@
|
||||
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||
import { isJobLive } from "../agent/subagents/aborts.ts";
|
||||
import { coverageOf } from "../core/coverage.ts";
|
||||
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
|
||||
import { titleFromPrompt } from "../core/contracts.ts";
|
||||
import { graphRevision } from "./graph.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
import { clearTurnFailure } from "./turn-runs.ts";
|
||||
import { discardChatDeliveries } from "./jobs.ts";
|
||||
|
||||
/**
|
||||
* Server-side chat entries for the transport layer (app/api/chats*,
|
||||
@@ -65,10 +68,7 @@ export type ChatDeleteOutcome =
|
||||
* the wind-down finds the chat gone at its merge and aborts with
|
||||
* nothing merged.
|
||||
*/
|
||||
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
|
||||
if (!stores.chatStore.getChat(chatId)) {
|
||||
return { ok: false, status: 404, code: "not_found", error: "No such session" };
|
||||
}
|
||||
function runningWorkBlocker(chatId: string): Extract<ChatDeleteOutcome, { status: 409 }> | null {
|
||||
if (isTurnRunning(chatId)) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -78,7 +78,11 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
||||
jobIds: [],
|
||||
};
|
||||
}
|
||||
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) => job.status === "running");
|
||||
// Legacy records kept completed workers running until delivery. A saved
|
||||
// result without a live loop is pending session data, not computing work;
|
||||
// deletion must not depend on a prior recovery poll to recognize it.
|
||||
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) =>
|
||||
job.status === "running" && (!job.result || isJobLive(job.id)));
|
||||
if (runningJobs.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -88,6 +92,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
||||
jobIds: runningJobs.map((job) => job.id),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
|
||||
if (!stores.chatStore.getChat(chatId)) {
|
||||
return { ok: false, status: 404, code: "not_found", error: "No such session" };
|
||||
}
|
||||
const blocker = runningWorkBlocker(chatId);
|
||||
if (blocker) return blocker;
|
||||
if (extractionStatuses().some((run) => run.chatId === chatId)) {
|
||||
abortExtraction(chatId); // true cancellation — spend stops, nothing merges
|
||||
const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS;
|
||||
@@ -104,11 +117,19 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
||||
}
|
||||
}
|
||||
|
||||
// A result wake or a worker may have started during extraction wind-down.
|
||||
// Recheck immediately before the synchronous purge, with no await gap.
|
||||
const lateBlocker = runningWorkBlocker(chatId);
|
||||
if (lateBlocker) return lateBlocker;
|
||||
|
||||
// THE SYNC BLOCK — no awaits below (see the doc comment). Order:
|
||||
// ledgers → jobs → the chat record.
|
||||
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
||||
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
|
||||
stores.chatStore.deleteChat(chatId);
|
||||
if (stores.chatStore.deleteChat(chatId)) {
|
||||
discardChatDeliveries(chatId);
|
||||
clearTurnFailure(chatId);
|
||||
}
|
||||
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
|
||||
/** Fresh admission state, not a process-wide cache. Existing runs retain
|
||||
* their engines; only new chat/job/extraction/compaction admissions consult it. */
|
||||
export function claudeCodeEnabled(): boolean {
|
||||
return settingsStore.readSettings()?.claudeCode?.enabled === true;
|
||||
}
|
||||
|
||||
/** Installation/opt-in availability only, never authenticated readiness. */
|
||||
export function claudeCodeAvailable(): boolean {
|
||||
return claudeCodeEnabled() && getClaudeBinary() !== null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
|
||||
import { initAuthEnv } from "./keys.ts";
|
||||
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
|
||||
import { claudeCodeEnabled } from "./claude-code-config.ts";
|
||||
|
||||
/** The Settings status seam: initialize provenance before any child env is copied. */
|
||||
export async function claudeCodeInstallationStatus() {
|
||||
initAuthEnv();
|
||||
return inspectClaudeInstallation();
|
||||
}
|
||||
|
||||
/** Same-origin browser intent, not user authentication. No CORS grants.
|
||||
* A public/shared server still requires its own access-control boundary. */
|
||||
export async function claudeCodeInstallationResponse(
|
||||
request: Request,
|
||||
inspect = claudeCodeInstallationStatus,
|
||||
enabled = claudeCodeEnabled,
|
||||
): Promise<Response> {
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
const supplied = request.headers.get("origin");
|
||||
const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
|
||||
if (request.method !== "GET" || !origin
|
||||
|| request.headers.get("x-learnharness-claude-code") !== "1"
|
||||
|| request.headers.get("sec-fetch-site") === "cross-site"
|
||||
|| (supplied !== null && supplied !== origin)) {
|
||||
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
|
||||
}
|
||||
if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
|
||||
return Response.json(await inspect(), { headers });
|
||||
}
|
||||
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const { text } = await runSubagent([], {
|
||||
const { text, newMessages } = await runSubagent([], {
|
||||
// The engine override is always present (the chat's own conversation
|
||||
// engine — owner ruling), so the role label never resolves an
|
||||
// engine; "researcher" is the least-wrong existing ModelRole label
|
||||
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
||||
usage = u;
|
||||
},
|
||||
});
|
||||
// A provider-declared incomplete response is not a usable checkpoint,
|
||||
// even when it contains nonempty text. Match Pi's compaction failure
|
||||
// semantics: leave the existing history and prior summary untouched.
|
||||
if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) {
|
||||
throw new Error("Compaction summary hit the output limit and is incomplete");
|
||||
}
|
||||
return { summary: text, usage };
|
||||
}
|
||||
|
||||
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
spanStart = prevKept;
|
||||
priorSummary = previous.content;
|
||||
}
|
||||
const spanText = serializeForSummary(messages.slice(spanStart, cut));
|
||||
const span = messages.slice(spanStart, cut);
|
||||
// The separately supplied prior summary replaces all summary markers
|
||||
// in this span. Without a resolved prior boundary, retain the existing
|
||||
// legacy fallback rather than silently discarding summary-only context.
|
||||
const summaryMessages = priorSummary
|
||||
? span.filter((m) => m.role !== "custom" || m.customType !== "compaction")
|
||||
: span;
|
||||
const spanText = serializeForSummary(summaryMessages);
|
||||
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
|
||||
|
||||
const prompt = buildCompactionPrompt({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Custom-only credential composition. File credentials and their authorization
|
||||
* are read from the same atomic record, never a stale fill-gapped env copy. */
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { authEnvRecord } from "../core/auth-env.ts";
|
||||
import { customBindingMatches, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
import type { CustomProviderSettings } from "../core/settings.ts";
|
||||
import { readCustomAuth, writeCustomAuth } from "../store/auth.ts";
|
||||
|
||||
export interface CustomAuthState {
|
||||
readable: boolean;
|
||||
binding?: CustomAuthBinding;
|
||||
key?: string;
|
||||
fileKey?: string;
|
||||
source: "env" | "file" | "none";
|
||||
}
|
||||
/** requiredConfig prevents even credential resolution until startup/runtime
|
||||
* authorization matches. Explicit UI inspection/probe authorization omits it. */
|
||||
export function customAuthState(inspectKey = false, requiredConfig?: CustomProviderSettings): CustomAuthState {
|
||||
try {
|
||||
const snapshot = readCustomAuth();
|
||||
const result: CustomAuthState = { readable: true, binding: snapshot.binding, source: "none" };
|
||||
if (!inspectKey || (requiredConfig && !customBindingMatches(requiredConfig, snapshot.binding))) return result;
|
||||
result.fileKey = snapshot.fileKey();
|
||||
const record = authEnvRecord();
|
||||
const envKey = process.env.LH_IGNORE_ENV_KEYS !== "1" && record.realEnv.has(CUSTOM_PROVIDER.envVar) ? process.env[CUSTOM_PROVIDER.envVar] : undefined;
|
||||
result.source = envKey !== undefined ? "env" : result.fileKey !== undefined ? "file" : "none";
|
||||
result.key = (envKey ?? result.fileKey)?.trim() || undefined;
|
||||
return result;
|
||||
} catch { return { readable: false, source: "none" }; }
|
||||
}
|
||||
export function customKeyAuthorized(cp: CustomProviderSettings | undefined, state: CustomAuthState): boolean {
|
||||
return cp?.authMode === "apiKey" && state.readable && customBindingMatches(cp, state.binding) && Boolean(state.key) && (state.source === "env" || state.binding?.fileKeyBaseUrl === cp?.baseUrl);
|
||||
}
|
||||
/** Reflect only after the atomic pair is committed. Runtime file resolution
|
||||
* does not rely on this compatibility mirror, including after a failed write. */
|
||||
export function persistCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
|
||||
writeCustomAuth(binding, fileKey);
|
||||
const record = authEnvRecord();
|
||||
if (!record.realEnv.has(CUSTOM_PROVIDER.envVar)) {
|
||||
if (fileKey === undefined) { delete process.env[CUSTOM_PROVIDER.envVar]; record.fillGap.delete(CUSTOM_PROVIDER.envVar); }
|
||||
else { process.env[CUSTOM_PROVIDER.envVar] = fileKey; record.fillGap.add(CUSTOM_PROVIDER.envVar); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Byte-bounded JSON for the custom endpoint's request/discovery surfaces. */
|
||||
export class CustomBodyLimitError extends Error {}
|
||||
|
||||
export async function readBoundedJson(message: Request | Response, limit: number, allowEmptyObject = false): Promise<unknown> {
|
||||
if (Number(message.headers.get("content-length")) > limit) {
|
||||
await message.body?.cancel();
|
||||
throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
|
||||
}
|
||||
const reader = message.body?.getReader();
|
||||
if (!reader) {
|
||||
if (allowEmptyObject) return {};
|
||||
throw new SyntaxError("Missing JSON body");
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
if (bytes === 0 && allowEmptyObject) return {};
|
||||
return JSON.parse(text + decoder.decode());
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** PR #62's connect/manual-model/offline-pin workflow, with host request,
|
||||
* credential-binding and optimistic-write boundaries. No secret reaches GET. */
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides, type CustomProviderSettings } from "../core/settings.ts";
|
||||
import { readSettings, writeSettings } from "../store/settings.ts";
|
||||
import { readCustomAuth } from "../store/auth.ts";
|
||||
import { customAuthState, customKeyAuthorized, persistCustomAuth } from "./custom-provider-auth.ts";
|
||||
import type { CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
import { desktopAPIAuthorized } from "./desktop-access.ts";
|
||||
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
|
||||
import { discoverModels, mergeManualModels, syncCustomProvider } from "./custom-provider.ts";
|
||||
import { parseCustomModelOverridesBody } from "./settings-body.ts";
|
||||
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
|
||||
|
||||
const revisionKey = Symbol.for("learnharness.custom-provider-revision");
|
||||
const globals = globalThis as typeof globalThis & { [revisionKey]?: Buffer };
|
||||
const secret = globals[revisionKey] ??= randomBytes(32);
|
||||
const failure = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
const stale = () => failure("Custom configuration or credentials changed. Reload configuration and retry.", 409);
|
||||
|
||||
function snapshot(inspectKey = false) {
|
||||
const record = readSettings() ?? { version: 1 };
|
||||
const cp = record.customProvider;
|
||||
// Keyless never looks up a key for authentication or revision generation.
|
||||
// An explicit API-key inspection is used only after the user selects that mode.
|
||||
const auth = customAuthState(inspectKey);
|
||||
const key = auth.key;
|
||||
// Public revisions contain configuration only, never credential material.
|
||||
// Effective credentials are compared privately across the awaited probe.
|
||||
const etag = `"${createHmac("sha256", secret).update(JSON.stringify([cp ?? null, auth.binding ?? null, auth.readable])).digest("base64url")}"`;
|
||||
return { record, cp, auth, key, etag };
|
||||
}
|
||||
|
||||
function response(body: object) {
|
||||
return Response.json(body, { headers: { etag: snapshot().etag, "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
export function customProviderGet(request: Request): Response {
|
||||
const inspect = new URL(request.url).searchParams.get("authMode") === "apiKey";
|
||||
const current = snapshot();
|
||||
const s = snapshot(inspect || current.cp?.authMode === "apiKey");
|
||||
return response({
|
||||
baseUrl: s.cp?.baseUrl ?? "", authMode: s.cp?.authMode ?? "keyless",
|
||||
models: s.cp?.models ?? [], overrides: s.cp?.overrides ?? {}, manualModels: s.cp?.manualModels ?? [],
|
||||
keyPresent: Boolean(s.key), keySource: s.auth.source,
|
||||
keyReuseRequired: Boolean(s.key) && !customKeyAuthorized(s.cp, s.auth),
|
||||
});
|
||||
}
|
||||
|
||||
function authorized(request: Request): boolean {
|
||||
const desktop = process.env.LH_DESKTOP_SERVER === "1" || Boolean(process.env.LH_DESKTOP_API_SECRET);
|
||||
if (desktop) return desktopAPIAuthorized(request.headers);
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin || origin === "null") return false;
|
||||
try {
|
||||
// Next's standalone Request URL can use its internal hostname. Match
|
||||
// the actual addressed Host through the app's existing origin seam;
|
||||
// never trust forwarded-origin headers.
|
||||
return origin === subscriptionRequestOrigin(request)
|
||||
&& (!request.headers.has("sec-fetch-site") || request.headers.get("sec-fetch-site") === "same-origin");
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Auth pair FIRST, matching config nonce SECOND. Any interrupted/failed
|
||||
* cross-file publication is durably unusable, including with a real env key.
|
||||
* Best-effort rollback restores the entire old pair, never just its secret. */
|
||||
function commit(cp: CustomProviderSettings | undefined, binding?: CustomAuthBinding, fileKey?: string): void {
|
||||
const latest = readSettings() ?? { version: 1 };
|
||||
const oldAuth = binding ? readCustomAuth() : undefined;
|
||||
let published = false;
|
||||
try {
|
||||
if (binding) { persistCustomAuth(binding, fileKey); published = true; }
|
||||
const { customProvider: _old, ...rest } = latest;
|
||||
writeSettings(cp ? { ...rest, customProvider: cp } : rest);
|
||||
} catch {
|
||||
if (published && oldAuth) {
|
||||
try { persistCustomAuth(oldAuth.binding, oldAuth.fileKey()); } catch { /* durable nonce mismatch fails closed after restart */ }
|
||||
}
|
||||
throw new Error("Unable to save custom configuration. Reload configuration and retry.");
|
||||
}
|
||||
syncCustomProvider();
|
||||
}
|
||||
|
||||
export async function customProviderMutation(request: Request): Promise<Response> {
|
||||
if (!authorized(request)) return failure("Custom configuration requires a trusted same-origin request.", 403);
|
||||
if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") return failure("JSON content-type is required.", 415);
|
||||
try {
|
||||
// Next can represent an empty DELETE as a zero-byte stream rather
|
||||
// than null. Bound it either way; only DELETE permits an empty body.
|
||||
const body = await readBoundedJson(request, 64 * 1024, request.method === "DELETE");
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return failure("Invalid JSON body");
|
||||
const raw = body as Record<string, unknown>;
|
||||
const before = snapshot(raw.authMode === "apiKey");
|
||||
if (!before.auth.readable) return failure("Custom authorization storage is unavailable. No changes were saved.", 500);
|
||||
if (request.headers.get("if-match") !== before.etag) return stale();
|
||||
if (request.method === "DELETE") {
|
||||
commit(undefined, { version: 1, id: randomBytes(16).toString("hex"), authMode: "disabled" });
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (request.method === "PATCH") {
|
||||
const parsed = parseCustomModelOverridesBody(raw);
|
||||
if (!parsed.ok) return failure(parsed.error);
|
||||
if (!before.cp?.baseUrl) return failure("No custom endpoint configured", 409);
|
||||
const overrides = sanitizeCustomModelOverrides(parsed.settings, before.cp.models?.map((m) => m.id));
|
||||
commit({ ...before.cp, overrides });
|
||||
return response({ ok: true, overrides });
|
||||
}
|
||||
if (request.method !== "POST") return failure("Unsupported method", 405);
|
||||
const base = normalizeBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : "");
|
||||
if (!base) return failure("Base URL must be http(s), with no credentials, query or fragment.");
|
||||
if (raw.authMode !== "keyless" && raw.authMode !== "apiKey") return failure("Choose Keyless or API key authentication.");
|
||||
const authMode = raw.authMode;
|
||||
if (raw.key !== undefined && typeof raw.key !== "string") return failure("API key must be a string.");
|
||||
const supplied = typeof raw.key === "string" ? raw.key.trim() : undefined;
|
||||
if (authMode === "apiKey" && supplied === "") return failure("Choose Keyless to clear authentication.");
|
||||
const envOwned = before.auth.source === "env";
|
||||
if (envOwned && supplied) return failure("Environment owns the effective key. Change the environment or explicitly reuse that key.");
|
||||
// A shadowed file key is not the key that will run. Require consent for
|
||||
// the actual environment key when authorizing a different endpoint.
|
||||
const hasNewEffectiveKey = Boolean(supplied) && !envOwned;
|
||||
const changedTarget = before.cp?.baseUrl !== base;
|
||||
if (authMode === "apiKey" && (changedTarget || !customKeyAuthorized(before.cp, before.auth)) && !hasNewEffectiveKey && raw.reuseKey !== true) {
|
||||
return failure("Enter a new key or explicitly allow reuse of the effective existing key for this URL.");
|
||||
}
|
||||
const effectiveKey = authMode === "apiKey" ? (envOwned ? before.key : supplied || before.key) : undefined;
|
||||
if (authMode === "apiKey" && !effectiveKey) return failure("API key mode requires an effective key.");
|
||||
const manualModels = parseModelIds(raw.manualModels);
|
||||
if (manualModels.length > 10_000) return failure("Model list exceeds 10000 entries");
|
||||
const found = await discoverModels(base, effectiveKey);
|
||||
if (!found.ok && (found.fatal || found.status === 401 || found.status === 403)) return failure(found.error, 502);
|
||||
if (!found.ok && !manualModels.length) return failure(`${found.error}. If this endpoint has no /models list, add model IDs manually.`, 502);
|
||||
// Discovery yields; both the custom record and the credential must still
|
||||
// match. Unrelated settings edits are preserved by commit's fresh merge.
|
||||
const now = snapshot(authMode === "apiKey");
|
||||
if (before.etag !== now.etag || JSON.stringify(before.auth) !== JSON.stringify(now.auth)) return stale();
|
||||
const discovered = found.ok ? found.models : !changedTarget ? before.cp?.models ?? [] : [];
|
||||
const models = mergeManualModels(discovered, manualModels);
|
||||
if (models.length > 10_000) return failure("Model list exceeds 10000 entries");
|
||||
const overrides = sanitizeCustomModelOverrides(!changedTarget ? before.cp?.overrides : undefined, models.map((m) => m.id));
|
||||
const id = randomBytes(16).toString("hex");
|
||||
const fileKey = authMode === "keyless" ? undefined : supplied && !envOwned ? supplied : now.auth.fileKey;
|
||||
const fileKeyBaseUrl = authMode === "apiKey" ? envOwned ? now.auth.binding?.fileKeyBaseUrl : base : undefined;
|
||||
const binding: CustomAuthBinding = { version: 1, id, baseUrl: base, authMode, ...(fileKey && fileKeyBaseUrl ? { fileKeyBaseUrl } : {}) };
|
||||
commit({ baseUrl: base, authMode, credentialBindingId: id, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, binding, fileKey);
|
||||
return response({ ok: true, models });
|
||||
} catch (error) {
|
||||
return failure(error instanceof CustomBodyLimitError ? "Configuration body exceeds 64 KiB." : error instanceof SyntaxError ? "Invalid JSON body." : "Unable to save custom configuration. Reload configuration and retry.", error instanceof CustomBodyLimitError ? 413 : error instanceof SyntaxError ? 400 : 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* The custom OpenAI-compatible endpoint: turns the user's non-secret config
|
||||
* (base URL + discovered models) into a pi-ai provider registered in
|
||||
* `piModels()`, so its models fill the same slots as any built-in. Keyless
|
||||
* local servers work via a placeholder key resolved at request time.
|
||||
*/
|
||||
|
||||
import { createProvider, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
||||
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||
import { configureProviderSource, piModels } from "../agent/models.ts";
|
||||
import { readSettings } from "../store/settings.ts";
|
||||
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { customBindingMatches } from "../core/custom-provider-auth.ts";
|
||||
import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts";
|
||||
import {
|
||||
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
customProviderUsable,
|
||||
normalizeBaseUrl,
|
||||
type CustomModel,
|
||||
type CustomModelOverride,
|
||||
} from "../core/settings.ts";
|
||||
|
||||
|
||||
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
|
||||
const KEYLESS_PLACEHOLDER = "not-needed";
|
||||
|
||||
/** Narrow an unknown to a plain object (not null, not an array). */
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Install the sole custom source: donor settings + explicit authentication.
|
||||
* Publication captures a consistent snapshot; an already-started operation
|
||||
* cannot acquire a later endpoint's key. No file configuration source exists. */
|
||||
export function installCustomProviderSource(readAuth = customAuthState): void {
|
||||
let fingerprint: string | undefined;
|
||||
configureProviderSource({
|
||||
isCustom: (id) => id === CUSTOM_PROVIDER.id,
|
||||
sync(models) {
|
||||
const cp = readSettings()?.customProvider;
|
||||
// Validate the durable nonce/URL/mode before resolving ANY credential.
|
||||
// Keyless reads only the nonsecret marker, never a file/env key.
|
||||
const auth = readAuth(cp?.authMode === "apiKey", cp);
|
||||
const key = auth.key;
|
||||
const ready = customProviderUsable(cp) && auth.readable && customBindingMatches(cp, auth.binding) && (cp.authMode === "keyless" || customKeyAuthorized(cp, auth));
|
||||
const next = JSON.stringify([cp, auth]); // Private in-memory fingerprint, never a public revision.
|
||||
if (next === fingerprint) return;
|
||||
if (ready) models.setProvider(buildOpenAiCompatibleProvider({ ...cp, baseUrl: cp.baseUrl, models: cp.models ?? [], apiKey: key }));
|
||||
else models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||
fingerprint = next;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function customProviderAvailable(): boolean {
|
||||
return piModels().getProvider(CUSTOM_PROVIDER.id) !== undefined;
|
||||
}
|
||||
|
||||
/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
|
||||
* levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
|
||||
* the user's pin wins, then the endpoint's own metadata, then the compiled
|
||||
* default — a local server that says nothing (or says 8k) can be corrected.
|
||||
* baseUrl is stamped per-model. */
|
||||
function toPiModel(
|
||||
model: CustomModel,
|
||||
baseUrl: string,
|
||||
overrides: Record<string, CustomModelOverride>,
|
||||
): Model<Api> {
|
||||
const pin = overrides[model.id];
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions",
|
||||
provider: CUSTOM_PROVIDER.id,
|
||||
baseUrl,
|
||||
// Pin wins; else detected from the endpoint's metadata (see
|
||||
// detectReasoning). true makes pi-ai offer the standard levels and send
|
||||
// reasoning_effort on the wire; no thinkingLevelMap → the default
|
||||
// off/minimal/low/medium/high set.
|
||||
reasoning: pin?.reasoning ?? Boolean(model.reasoning),
|
||||
input: model.image ? ["text", "image"] : ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
type: "chat",
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an OpenAI-compatible provider from parts. baseUrl must be normalized. */
|
||||
export function buildOpenAiCompatibleProvider(input: {
|
||||
baseUrl: string;
|
||||
models: readonly CustomModel[];
|
||||
/** User pins by model id (see CustomModelOverride) — absent = none. */
|
||||
overrides?: Record<string, CustomModelOverride>;
|
||||
/** Already resolved for this publication; absent means explicit keyless. */
|
||||
apiKey?: string;
|
||||
}): Provider<Api> {
|
||||
const overrides = input.overrides ?? {};
|
||||
return createProvider<Api>({
|
||||
id: CUSTOM_PROVIDER.id,
|
||||
name: CUSTOM_PROVIDER.label,
|
||||
baseUrl: input.baseUrl,
|
||||
auth: { apiKey: {
|
||||
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||
resolve: async () => ({ auth: { apiKey: input.apiKey ?? KEYLESS_PLACEHOLDER }, source: input.apiKey ? CUSTOM_PROVIDER.envVar : "keyless" }),
|
||||
} },
|
||||
models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
|
||||
* not usable → remove. Never throws. */
|
||||
export function syncCustomProvider(): void {
|
||||
piModels();
|
||||
}
|
||||
|
||||
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
|
||||
* when it answered at all, so callers can tell a rejected key from a missing
|
||||
* model list. */
|
||||
export type DiscoverResult =
|
||||
| { ok: true; models: CustomModel[] }
|
||||
| { ok: false; error: string; status?: number; fatal?: boolean };
|
||||
|
||||
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
|
||||
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
|
||||
* body → honest error, never a silent empty list. */
|
||||
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
||||
const key = apiKey?.trim();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const res = await fetch(`${base}/models`, {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
await res.body?.cancel();
|
||||
return { ok: false, error: "Discovery redirects are not allowed", fatal: true };
|
||||
}
|
||||
if (!res.ok) { await res.body?.cancel(); return { ok: false, error: `Endpoint returned HTTP ${res.status}`, status: res.status }; }
|
||||
const body = await readBoundedJson(res, 8 * 1024 * 1024);
|
||||
const list = extractModels(body);
|
||||
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
|
||||
return { ok: true, models: list };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The connect's model list: what the endpoint advertises, plus every typed id
|
||||
* it does not advertise (at default limits, so the pins can correct them).
|
||||
* Discovered metadata always wins over a typed id of the same name. */
|
||||
export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] {
|
||||
const byId = new Map(discovered.map((m) => [m.id, m]));
|
||||
for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id });
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Model rows from a list response ({data:[…]} | {models:[…]} | bare array),
|
||||
* or null if unrecognized. */
|
||||
function modelRows(body: unknown): unknown[] | null {
|
||||
if (Array.isArray(body)) return body;
|
||||
if (isRecord(body)) {
|
||||
if (Array.isArray(body.data)) return body.data;
|
||||
if (Array.isArray(body.models)) return body.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does a /models row advertise image input? Scans the common modality/
|
||||
* capability fields; absent or unrecognized → false (text-only). */
|
||||
function detectImage(row: Record<string, unknown>): boolean {
|
||||
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||
const candidates = [
|
||||
architecture?.input_modalities,
|
||||
row.input_modalities,
|
||||
row.modalities,
|
||||
row.supported_modalities,
|
||||
row.capabilities,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Does a /models row advertise reasoning? Metadata only — no name guessing,
|
||||
* because a false positive sends reasoning_effort an endpoint will reject.
|
||||
* Trusted signals: an explicit boolean (reasoning / supports_reasoning /
|
||||
* thinking) or a reasoning token in the capability/parameter lists servers
|
||||
* publish (vLLM + OpenRouter's supported_parameters, capabilities, tags).
|
||||
* Absent or unrecognized → false (thinking stays "off"). */
|
||||
function detectReasoning(row: Record<string, unknown>): boolean {
|
||||
for (const key of ["reasoning", "supports_reasoning", "thinking"] as const) {
|
||||
if (row[key] === true) return true;
|
||||
}
|
||||
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||
const candidates = [row.supported_parameters, row.capabilities, row.tags, architecture?.output_modalities];
|
||||
return candidates.some(
|
||||
(list) =>
|
||||
Array.isArray(list) &&
|
||||
list.some((v) => typeof v === "string" && (/reasoning/i.test(v) || v.toLowerCase() === "thinking")),
|
||||
);
|
||||
}
|
||||
|
||||
/** First positive integer among `keys` on a row (endpoints name limits
|
||||
* inconsistently). */
|
||||
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = row[key];
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Field names seen in the wild for each limit (first present wins).
|
||||
const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
|
||||
const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
|
||||
|
||||
/** Pull models from a list response (objects with `id` or bare strings), each
|
||||
* with detected image + token limits. Null if the shape is unrecognized. */
|
||||
function extractModels(body: unknown): CustomModel[] | null {
|
||||
const rows = modelRows(body);
|
||||
if (rows === null) return null;
|
||||
if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries");
|
||||
const byId = new Map<string, CustomModel>();
|
||||
for (const row of rows) {
|
||||
if (typeof row === "string") {
|
||||
if (row.trim()) byId.set(row, { id: row });
|
||||
continue;
|
||||
}
|
||||
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
|
||||
const model: CustomModel = { id: row.id };
|
||||
if (detectImage(row)) model.image = true;
|
||||
if (detectReasoning(row)) model.reasoning = true;
|
||||
const context = firstPositive(row, CONTEXT_KEYS);
|
||||
if (context) model.context = context;
|
||||
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
|
||||
if (maxTokens) model.maxTokens = maxTokens;
|
||||
byId.set(row.id, model);
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
@@ -43,7 +43,13 @@ export * from "./compaction.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
|
||||
export { applicationLanguage, chatLanguage } from "./language.ts";
|
||||
export * from "./stt.ts";
|
||||
export { claudeCodeInstallationResponse } from "./claude-code.ts";
|
||||
// The custom endpoint module (registers into piModels() + probes for models).
|
||||
// No import side effects; the boot sync lives in stores.ts.
|
||||
export * from "./custom-provider.ts";
|
||||
export { customProviderGet, customProviderMutation } from "./custom-provider-http.ts";
|
||||
|
||||
+185
-163
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
|
||||
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
|
||||
* delivery rides pi's OWN follow-up queue when a run is live, and the
|
||||
* delivery rides pi's OWN steering queue when a run is live, and the
|
||||
* idle wake otherwise. One registry, two vehicles.
|
||||
*
|
||||
* deliverJobResult(steer) — the runner calls this on job completion.
|
||||
@@ -40,15 +40,17 @@
|
||||
*
|
||||
* settleSteers(chatId, persisted) — the handler calls this at turn
|
||||
* settlement (after unregisterLiveAgent): entries the run DELIVERED
|
||||
* resolve + flip their jobs; entries that were handed to the dying
|
||||
* resolve + acknowledge delivery; entries handed to the dying
|
||||
* run but never injected (stopper exit, abort, a missed final poll)
|
||||
* return to pending and the pump restarts — no stranding, no loss.
|
||||
*
|
||||
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
|
||||
* "running" with a result (the wake never fired) or without one (the
|
||||
* sub-agent loop itself died). The sweep re-delivers finished results
|
||||
* (idempotently) and re-spawns unfinished loops, both via injected
|
||||
* seams. It runs lazily off the /api/jobs poll, throttled.
|
||||
* Worker status ends at computation completion; JobRecord.delivery owns
|
||||
* the pending result or abort notice independently. Registry entries
|
||||
* survive in-flight wakes, so duplicate submissions share one promise.
|
||||
* Failed starts stay pending without timers or worker errors. Recovery
|
||||
* restores known pending deliveries and re-spawns unfinished workers;
|
||||
* registration also restores pending delivery before the first poll.
|
||||
* Legacy terminal jobs without delivery metadata are never replayed.
|
||||
*
|
||||
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
|
||||
* gives each route entry its own module instances — a module-level Map would
|
||||
@@ -83,6 +85,14 @@ export type JobRespawn = (jobId: string) => void;
|
||||
|
||||
interface QueuedSteer {
|
||||
steer: SteerMessage;
|
||||
promise: Promise<void>;
|
||||
/** Wake cargo remains owned here, but must not steer into its own run. */
|
||||
waking?: boolean;
|
||||
/** The constructed vehicle, once startup claims a turn. Until then only
|
||||
* the pump owns waking: an unrelated foreground settlement cannot free it. */
|
||||
wakeAgent?: Agent;
|
||||
/** A failed start waits for a learner run or process restart, not a timer. */
|
||||
deferred?: boolean;
|
||||
resolve: () => void;
|
||||
reject: (reason: Error) => void;
|
||||
/** The live agent this marker was steered into (agent.steer) — set by
|
||||
@@ -166,18 +176,37 @@ function jobIdOf(steer: SteerMessage): string | null {
|
||||
return typeof details?.jobId === "string" ? details.jobId : null;
|
||||
}
|
||||
|
||||
/** Dock bookkeeping: the marker reached the learner's view — flip its job
|
||||
* NOW instead of waiting for the delivering turn to settle. Called from
|
||||
* BOTH visibility seams: the marker's message_end wire event (live steer
|
||||
* injection — the card is on screen while the teacher keeps teaching) and
|
||||
* the write-ahead persist in handleChatInput (the wake's input appends —
|
||||
* the card is durable in the record). The registry's delivery promise
|
||||
* still settles via settleSteers / the runner-200 path; finalizeJob is
|
||||
* idempotent, so the later flip is a no-op. Owner ruling (2026-09-26): a
|
||||
* delivered subagent leaves the background dock the moment its result is
|
||||
* visible, not when the teacher's turn ends. */
|
||||
export function noteSteerVisible(steer: SteerMessage): void {
|
||||
finalizeSteer(steer);
|
||||
/** Call only after canonical conversation persistence. Streaming visibility
|
||||
* is not durable acceptance. The saved marker wins over delivery-bookkeeping
|
||||
* failures; acknowledging delivery never changes the worker's outcome. */
|
||||
export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void {
|
||||
for (const message of persisted) {
|
||||
if (message.role === "custom") finalizeSteer(message);
|
||||
}
|
||||
for (const entry of [...(state().queues.get(chatId) ?? [])]) {
|
||||
if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntry(chatId: string, entry: QueuedSteer): void {
|
||||
const remaining = (state().queues.get(chatId) ?? []).filter((candidate) => candidate !== entry);
|
||||
if (remaining.length > 0) state().queues.set(chatId, remaining);
|
||||
else state().queues.delete(chatId);
|
||||
}
|
||||
|
||||
function completeEntry(chatId: string, entry: QueuedSteer): void {
|
||||
finalizeSteer(entry.steer);
|
||||
removeEntry(chatId, entry);
|
||||
entry.resolve();
|
||||
}
|
||||
|
||||
/** Deletion discards the session's obligations, never wakes a deleted chat. */
|
||||
export function discardChatDeliveries(chatId: string): void {
|
||||
const s = state();
|
||||
const queue = s.queues.get(chatId) ?? [];
|
||||
s.queues.delete(chatId);
|
||||
s.userSteers.delete(chatId);
|
||||
for (const entry of queue) entry.resolve();
|
||||
}
|
||||
|
||||
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
||||
@@ -186,24 +215,30 @@ function finalizeSteer(steer: SteerMessage): void {
|
||||
if (jobId) finalizeJob(jobId);
|
||||
}
|
||||
|
||||
/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
|
||||
function failSteer(steer: SteerMessage, err: Error): void {
|
||||
const jobId = jobIdOf(steer);
|
||||
if (jobId) failJob(jobId, err);
|
||||
/** Queue one logical delivery per ID, including in-flight wake cargo.
|
||||
* Job promises resolve on durable marker acceptance (or session deletion);
|
||||
* failed starts stay pending. Non-job probe handoffs keep their existing
|
||||
* wake-acceptance/error semantics. Workers never await this promise. */
|
||||
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
const chatId = chatIdOf(steer);
|
||||
const entry = queueSteer(chatId, steer);
|
||||
void pump(chatId);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
|
||||
* Resolves when the wake turn has completed; rejects when delivery fails
|
||||
* unrecoverably. Never rejects for queueing itself. */
|
||||
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const s = state();
|
||||
const chatId = chatIdOf(steer);
|
||||
const queue = s.queues.get(chatId) ?? [];
|
||||
queue.push({ steer, resolve, reject });
|
||||
s.queues.set(chatId, queue);
|
||||
void pump(chatId);
|
||||
});
|
||||
function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer {
|
||||
const queue = state().queues.get(chatId) ?? [];
|
||||
const existing = queue.find((entry) => entry.steer.id === steer.id);
|
||||
if (existing) return existing;
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej; });
|
||||
// Recovery/registration may own an entry with no waiting caller.
|
||||
void promise.catch(() => undefined);
|
||||
const entry: QueuedSteer = { steer, promise, resolve, reject };
|
||||
queue.push(entry);
|
||||
state().queues.set(chatId, queue);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Hand every eligible pending marker to a live agent's steering queue
|
||||
@@ -227,7 +262,7 @@ function handOffEligible(chatId: string, agent: Agent): void {
|
||||
// remain deliverable, including the first poll of an answer run.
|
||||
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
||||
for (const entry of queue) {
|
||||
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||
if (!entry.waking && !entry.deferred && entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||
agent.steer(entry.steer);
|
||||
entry.handedTo = agent;
|
||||
}
|
||||
@@ -254,8 +289,13 @@ function handOffEligible(chatId: string, agent: Agent): void {
|
||||
* markers are steered into the run right here. The settle seam cannot
|
||||
* beat the registration: settlement needs a streamed response, which
|
||||
* never happens inside the constructor call. */
|
||||
export function registerLiveAgent(chatId: string, agent: Agent): void {
|
||||
export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true, wakeSteerIds?: ReadonlySet<string>): void {
|
||||
state().liveAgents.set(chatId, agent);
|
||||
restorePendingDeliveries(chatId);
|
||||
for (const entry of state().queues.get(chatId) ?? []) {
|
||||
if (allowDeferred) entry.deferred = false;
|
||||
if (entry.waking && wakeSteerIds?.has(entry.steer.id)) entry.wakeAgent = agent;
|
||||
}
|
||||
handOffEligible(chatId, agent);
|
||||
}
|
||||
|
||||
@@ -326,7 +366,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
|
||||
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
||||
*
|
||||
* - entries whose marker the run DELIVERED (in-run follow-up injection —
|
||||
* their ids are in the persisted slice) resolve and flip their jobs;
|
||||
* their ids are in the persisted slice) resolve and acknowledge delivery;
|
||||
* - entries handed to the dying run but never injected (the question
|
||||
* stopper exited before the follow-up poll; an abort; a delivery that
|
||||
* raced the final poll) return to PENDING (handedTo cleared) and the
|
||||
@@ -335,7 +375,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
|
||||
* This is what makes the live hand-off safe: pi owns no retry for a marker
|
||||
* stranded in a settled agent's queue, so the registry stays the truth and
|
||||
* the dead agent's copy is simply abandoned (GC'd with the agent). */
|
||||
export function settleSteers(chatId: string, persisted: { id?: string }[]): void {
|
||||
export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false, settledAgent?: Agent): void {
|
||||
const queue = state().queues.get(chatId);
|
||||
if (!queue || queue.length === 0) return;
|
||||
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
|
||||
@@ -345,7 +385,11 @@ export function settleSteers(chatId: string, persisted: { id?: string }[]): void
|
||||
finalizeSteer(entry.steer);
|
||||
entry.resolve();
|
||||
} else {
|
||||
entry.handedTo = undefined;
|
||||
const handedHere = entry.handedTo !== undefined && (!settledAgent || entry.handedTo === settledAgent);
|
||||
const wakeHere = settledAgent !== undefined && entry.wakeAgent === settledAgent;
|
||||
if (handedHere) entry.handedTo = undefined;
|
||||
if (wakeHere) { entry.waking = false; entry.wakeAgent = undefined; }
|
||||
if (failed && (handedHere || wakeHere) && jobIdOf(entry.steer)) entry.deferred = true;
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -373,16 +417,10 @@ export interface AbortOutcome {
|
||||
* runner registered it at loop start) — the in-flight model call
|
||||
* aborts, not just the next loop check. The runner unwinds silently
|
||||
* (its catch checks abortSignal.aborted; finishJob checks status).
|
||||
* 2. DROP any queued result steer for this job (the delivering-result
|
||||
* phase): resolve its entry without firing the wake. If the wake
|
||||
* turn is ALREADY in flight (the pump shifted the entry), it's too
|
||||
* late — the result steer lands, and the cancellation steer dedupes
|
||||
* against it via steerDelivered (same jobId metadata).
|
||||
* 3. FLIP the record to error "Aborted by learner" immediately — the
|
||||
* poll reflects the user's action at once (the user chose
|
||||
* error-with-reason over a distinct cancelled status). finalizeJob
|
||||
* never overwrites a non-running status, so the flip is terminal no
|
||||
* matter what the wake does.
|
||||
* 2. REFUSE if computation already finished. Queued results belong to
|
||||
* the session, not to an abortable background worker.
|
||||
* 3. SAVE error "Aborted by learner" and a pending abort delivery together.
|
||||
* Delivery acknowledgement never overwrites the worker's outcome.
|
||||
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
|
||||
* the same wake pipeline, so the teacher's promise of a result
|
||||
* doesn't hang silently in the lesson.
|
||||
@@ -394,28 +432,18 @@ export function abortJob(jobId: string): AbortOutcome {
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
if (!job) return { ok: false, reason: "not_found" };
|
||||
if (job.status !== "running") return { ok: false, reason: "not_running" };
|
||||
|
||||
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
|
||||
|
||||
// Drop any queued steers for this job (their deliver() resolves unfired).
|
||||
// HANDED entries (already in a live run's follow-up queue) are past
|
||||
// recall — same ruling as a fired wake: the result lands, and the abort
|
||||
// note below dedupes against it via steerDelivered (same jobId).
|
||||
const queue = state().queues.get(job.chatId);
|
||||
if (queue) {
|
||||
const remaining = queue.filter(
|
||||
(e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined,
|
||||
);
|
||||
if (remaining.length === 0) state().queues.delete(job.chatId);
|
||||
else state().queues.set(job.chatId, remaining);
|
||||
for (const entry of queue) {
|
||||
if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve();
|
||||
}
|
||||
if (job.result) {
|
||||
normalizeLegacyResult(job);
|
||||
return { ok: false, reason: "not_running" };
|
||||
}
|
||||
|
||||
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
|
||||
fireJobAbort(jobId);
|
||||
stores.jobStore.updateJob(jobId, {
|
||||
status: "error", error: "Aborted by learner", activity: "aborted",
|
||||
delivery: { kind: "abort", status: "pending" },
|
||||
});
|
||||
void deliverJobResult(buildAbortSteer(job)).catch(() => {
|
||||
/* deliverJobResult marks the job error on unrecoverable failure */
|
||||
/* The saved pending abort notice remains owned by the session. */
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -470,18 +498,9 @@ async function pump(chatId: string): Promise<void> {
|
||||
// Idempotent delivery: the transcript already carries these steers
|
||||
// (an in-run injection persisted, a wake completed, or a duplicate
|
||||
// deliver raced in). Resolve and flip without re-firing anything.
|
||||
const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id));
|
||||
for (const entry of queue) {
|
||||
if (!stillPending.includes(entry)) {
|
||||
finalizeSteer(entry.steer);
|
||||
entry.resolve();
|
||||
}
|
||||
}
|
||||
if (stillPending.length === 0) {
|
||||
s.queues.delete(chatId);
|
||||
return;
|
||||
}
|
||||
s.queues.set(chatId, stillPending);
|
||||
noteSteersDurable(chatId, record.messages);
|
||||
const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred);
|
||||
if (stillPending.length === 0) { parked = true; return; }
|
||||
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
||||
|
||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||
@@ -526,28 +545,17 @@ async function pump(chatId: string): Promise<void> {
|
||||
// The runner is required — chat-handler sets it at init. If some
|
||||
// module instance missed the wiring, fail loudly rather than hang.
|
||||
if (!s.turnRunner) {
|
||||
s.queues.delete(chatId);
|
||||
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
|
||||
for (const entry of stillPending) {
|
||||
failSteer(entry.steer, err);
|
||||
entry.reject(err);
|
||||
}
|
||||
for (const entry of stillPending) deferOrReject(chatId, entry, err);
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A live run may have delivered some markers while the gates were
|
||||
// closed — re-check: only still-missing markers ride the wake.
|
||||
const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id));
|
||||
for (const entry of stillPending) {
|
||||
if (!fire.includes(entry)) {
|
||||
finalizeSteer(entry.steer);
|
||||
entry.resolve();
|
||||
}
|
||||
}
|
||||
if (fire.length === 0) {
|
||||
s.queues.delete(chatId);
|
||||
return;
|
||||
}
|
||||
noteSteersDurable(chatId, fresh.messages);
|
||||
const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo);
|
||||
if (fire.length === 0) return;
|
||||
|
||||
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
|
||||
// ends in a user dismissal — the session waits for the USER. Hold
|
||||
@@ -561,50 +569,37 @@ async function pump(chatId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
|
||||
// the carried markers leave the registry BEFORE the turn runs —
|
||||
// the wake run's OWN follow-up poll must never see its own cargo,
|
||||
// and a 409 (client raced in) must be able to push them back.
|
||||
s.queues.delete(chatId);
|
||||
// Keep ownership through the wake. The flag excludes cargo from
|
||||
// its own live-agent handoff while duplicate callers share it.
|
||||
for (const entry of fire) entry.waking = true;
|
||||
const steers = fire.map((e) => e.steer);
|
||||
|
||||
// Fire the wake (every marker as the turn's input appends). A 409 =
|
||||
// a client turn raced in between idle and firing — push the cargo
|
||||
// back and retry after backoff (the racing turn's own settlement
|
||||
// re-pumps if it beats us to the re-queue).
|
||||
// Fire the wake with cargo still registered. A 409 releases its
|
||||
// vehicle flag and retries after the existing backoff, without
|
||||
// appending another registry entry.
|
||||
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
|
||||
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
||||
return -1;
|
||||
});
|
||||
if (status === 403) {
|
||||
// Consent may have changed after the last gate. Preserve completed
|
||||
// job/result ownership and wait for an explicit connection change.
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
s.queues.set(chatId, back);
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
if (status === 409) {
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
s.queues.set(chatId, back);
|
||||
const saved = stores.chatStore.getChat(chatId);
|
||||
if (saved) noteSteersDurable(chatId, saved.messages);
|
||||
const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry));
|
||||
if (status === 403 || status === 409) {
|
||||
for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; }
|
||||
if (status === 403) { parked = true; return; }
|
||||
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
|
||||
continue;
|
||||
}
|
||||
attempt = 0;
|
||||
|
||||
if (status === 200) {
|
||||
for (const entry of fire) {
|
||||
finalizeSteer(entry.steer);
|
||||
entry.resolve();
|
||||
}
|
||||
} else {
|
||||
const err = new Error(`wake turn failed with status ${status}`);
|
||||
for (const entry of fire) {
|
||||
failSteer(entry.steer, err);
|
||||
entry.reject(err);
|
||||
for (const entry of owned) {
|
||||
if (status === 200 && !jobIdOf(entry.steer)) {
|
||||
// Preserve the non-job probe handoff's acceptance semantics.
|
||||
completeEntry(chatId, entry);
|
||||
} else if (status !== 200 || !isTurnRunning(chatId)) {
|
||||
deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`));
|
||||
}
|
||||
// An accepted live turn still owns its cargo until canonical save
|
||||
// or settlement. HTTP 200 alone is not a durable marker receipt.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -614,7 +609,7 @@ async function pump(chatId: string): Promise<void> {
|
||||
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
||||
// — restarting for them would just park in waitForIdle forever.
|
||||
const q = state().queues.get(chatId) ?? [];
|
||||
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined && !e.waking && !e.deferred)) void pump(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,25 +633,56 @@ state().stopConnectionWatch = onSubscriptionChange(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Flip the job to its final status now that the lesson has resumed. */
|
||||
function finalizeJob(jobId: string): void {
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
if (!job || job.status !== "running") return;
|
||||
const result = job.result;
|
||||
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
|
||||
stores.jobStore.updateJob(jobId, {
|
||||
status: failed ? "error" : "done",
|
||||
activity: failed ? "failed" : "delivered",
|
||||
});
|
||||
function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void {
|
||||
entry.waking = false;
|
||||
entry.wakeAgent = undefined;
|
||||
if (jobIdOf(entry.steer)) {
|
||||
entry.deferred = true;
|
||||
console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message);
|
||||
} else {
|
||||
removeEntry(chatId, entry);
|
||||
entry.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function failJob(jobId: string, err: Error): void {
|
||||
// An aborted job stays aborted even when its (abort) steer
|
||||
// delivery fails — the user's action is the terminal fact, not the
|
||||
// delivery error. Non-running records are never overwritten here.
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
if (!job || job.status !== "running") return;
|
||||
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
|
||||
/** Acknowledge session delivery without changing the worker outcome. */
|
||||
function finalizeJob(jobId: string): void {
|
||||
try {
|
||||
let job = stores.jobStore.getJob(jobId);
|
||||
if (!job) return;
|
||||
if (!job.delivery) job = normalizeLegacyResult(job);
|
||||
if (job.delivery?.status !== "pending") return;
|
||||
stores.jobStore.updateJob(jobId, { delivery: { ...job.delivery, status: "delivered" } });
|
||||
} catch (error) {
|
||||
// The conversation is already durable. A bookkeeping failure must
|
||||
// never replay that marker or turn a successful worker into a failure.
|
||||
console.error(`[job ${jobId}] delivery acknowledgement failed:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyResult(job: JobRecord): JobRecord {
|
||||
if (job.delivery || job.status !== "running" || !job.result) return job;
|
||||
const failed = isSubagentFailure(job.kind, job.result as never);
|
||||
return stores.jobStore.updateJob(job.id, {
|
||||
status: failed ? "error" : "done", activity: failed ? "failed" : "finished",
|
||||
delivery: { kind: "result", status: "pending" },
|
||||
}) ?? job;
|
||||
}
|
||||
|
||||
/** Restore known obligations only; old terminal records are never replayed. */
|
||||
function restorePendingDeliveries(chatId: string): void {
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
noteSteersDurable(chatId, record.messages);
|
||||
for (const stored of stores.jobStore.listJobs(chatId)) {
|
||||
const job = isJobLive(stored.id) ? stored : normalizeLegacyResult(stored);
|
||||
if (job.delivery?.status !== "pending") continue;
|
||||
const steer = job.delivery.kind === "abort" ? buildAbortSteer(job)
|
||||
: job.result ? buildSubagentSteer(job, job.result as never) : undefined;
|
||||
if (!steer) continue;
|
||||
if (steerDelivered(record.messages, steer.id)) finalizeSteer(steer);
|
||||
else queueSteer(chatId, steer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,7 +695,7 @@ function failJob(jobId: string, err: Error): void {
|
||||
async function waitForIdle(chatId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
if (!record || !(state().queues.get(chatId) ?? []).some((entry) => !entry.waking && !entry.deferred && !entry.handedTo)) return;
|
||||
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
|
||||
if (!blocked) {
|
||||
await delay(GRACE_MS);
|
||||
@@ -699,9 +725,9 @@ const SWEEP_THROTTLE_MS = 30_000;
|
||||
* hung-but-alive loops beside themselves — the figure-maker incident,
|
||||
* 2026-09-18). A record WITH a live registration is provably executing
|
||||
* in THIS process — never touched, however old: a hung first model call
|
||||
* (zero usage ever billed) stays honestly "running" in the dock until
|
||||
* END kills it. The abort registration spans loop start to delivery
|
||||
* completion, so a live registration can never belong to a dead run.
|
||||
* stays honestly running until END kills it. Terminal workers with known
|
||||
* pending delivery are restored independently; old terminal records without
|
||||
* delivery metadata are never inferred to owe a delivery.
|
||||
*/
|
||||
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
|
||||
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
|
||||
@@ -713,18 +739,14 @@ export function recoverStaleJobs(): void {
|
||||
try {
|
||||
for (const chat of stores.chatStore.listChats()) {
|
||||
for (const job of stores.jobStore.listJobs(chat.id)) {
|
||||
if (job.status !== "running") continue;
|
||||
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
|
||||
if (isJobLive(job.id)) continue; // never re-run beside live computation
|
||||
if (!stores.chatStore.getChat(job.chatId)) continue;
|
||||
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
|
||||
if (job.result) {
|
||||
// The wake never fired (or crashed mid-flight): re-deliver.
|
||||
const steer = buildSubagentSteer(job, job.result as never);
|
||||
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
|
||||
void deliverJobResult(steer).catch(() => {
|
||||
/* deliverJobResult marks the job error on failure */
|
||||
});
|
||||
} else {
|
||||
if (isTurnRunning(job.chatId)) continue;
|
||||
const recovered = normalizeLegacyResult(job);
|
||||
if (recovered.delivery?.status === "pending") {
|
||||
restorePendingDeliveries(job.chatId);
|
||||
void pump(job.chatId);
|
||||
} else if (job.status === "running" && !job.result) {
|
||||
// The sub-agent loop itself died with the process: re-spawn.
|
||||
if (!s.respawn) continue;
|
||||
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
|
||||
|
||||
+27
-24
@@ -43,6 +43,7 @@ import {
|
||||
AUTH_KEYS,
|
||||
AUTH_PROVIDER_KEYS,
|
||||
AUTH_PROVIDER_ENV_VARS,
|
||||
CUSTOM_PROVIDER,
|
||||
type AuthKeyInfo,
|
||||
type AuthKeyStatus,
|
||||
genericProviderKeyGuard,
|
||||
@@ -53,29 +54,19 @@ import {
|
||||
} from "../core/auth-keys.ts";
|
||||
import { authStore } from "../store/auth.ts";
|
||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||
import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts";
|
||||
import { claudeCodeAvailable } from "./claude-code-config.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
import { CUSTOM_AUTH_BINDING } from "../core/custom-provider-auth.ts";
|
||||
|
||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||
// env). Same pattern as globalThis.__lhExtractionRuns.
|
||||
interface AuthBoot {
|
||||
/** Vars that were in the REAL env at first init — env precedence's
|
||||
* untouchable set. Fill-gapped vars are everything else we set. */
|
||||
realEnv: Set<string>;
|
||||
/** Vars WE put into process.env (the file's reflection) — deletable. */
|
||||
fillGap: Set<string>;
|
||||
}
|
||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||
// without importing this store-backed module into the agent layer.
|
||||
import { authEnvRecord as boot } from "../core/auth-env.ts";
|
||||
|
||||
function boot(): AuthBoot {
|
||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
|
||||
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
|
||||
* var (provider-keys interlude: PUT/DELETE accept both). */
|
||||
/** Ordinary writable key names. Custom auth writes must use the atomic
|
||||
* endpoint-binding seam, never the generic single-key API. */
|
||||
export function isWritableAuthKey(id: string): boolean {
|
||||
return isKnownAuthKey(id) || isProviderEnvVar(id);
|
||||
return id !== CUSTOM_AUTH_BINDING && id !== CUSTOM_PROVIDER.envVar && (isKnownAuthKey(id) || isProviderEnvVar(id));
|
||||
}
|
||||
|
||||
/** The settings page's provider dropdown: every pi-ai provider that takes
|
||||
@@ -101,7 +92,7 @@ export function envKeysIgnored(): boolean {
|
||||
*/
|
||||
export function initAuthEnv(): void {
|
||||
const record = boot();
|
||||
const fileVars = authStore.authVars();
|
||||
const fileVars = Object.fromEntries(Object.entries(authStore.authVars()).filter(([name]) => name !== CUSTOM_AUTH_BINDING));
|
||||
const managed = new Set<string>([
|
||||
...Object.keys(fileVars),
|
||||
...AUTH_KEYS.map((k) => k.id),
|
||||
@@ -109,6 +100,7 @@ export function initAuthEnv(): void {
|
||||
// an env-exported OPENAI_API_KEY with a file copy gets honest shadow
|
||||
// semantics, exactly like the curated vars.
|
||||
...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
|
||||
CUSTOM_PROVIDER.envVar,
|
||||
]);
|
||||
if (envKeysIgnored()) {
|
||||
// THE FRESH-USER SWITCH: delete every managed env var (re-run on
|
||||
@@ -136,7 +128,7 @@ export function initAuthEnv(): void {
|
||||
/** Is this env-var name one the keys system manages (curated, provider,
|
||||
* or held in the file)? */
|
||||
function isManagedName(name: string): boolean {
|
||||
return isWritableAuthKey(name) || name in authStore.authVars();
|
||||
return name !== CUSTOM_AUTH_BINDING && (isWritableAuthKey(name) || name in authStore.authVars());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +150,7 @@ export function resolveKeyStatuses(
|
||||
}
|
||||
const rows: AuthKeyStatus[] = [];
|
||||
for (const id of ids) {
|
||||
if (id === CUSTOM_AUTH_BINDING) continue;
|
||||
const envValue = env[id];
|
||||
const fileValue = fileVars[id];
|
||||
const isRealEnv = realEnv.has(id);
|
||||
@@ -169,7 +162,9 @@ export function resolveKeyStatuses(
|
||||
id,
|
||||
info: AUTH_KEYS.find((k) => k.id === id) ?? null,
|
||||
present: live !== undefined,
|
||||
masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null,
|
||||
// Custom keys may be one character: even a masked suffix can expose
|
||||
// the entire credential. Preserve inventory, return no key characters.
|
||||
masked: live !== undefined ? id === CUSTOM_PROVIDER.envVar ? "••••" : `${maskKey(live)} (${live.trim().length} chars)` : null,
|
||||
source: isRealEnv ? "env" : live !== undefined ? "file" : "none",
|
||||
shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue,
|
||||
});
|
||||
@@ -179,7 +174,7 @@ export function resolveKeyStatuses(
|
||||
|
||||
/** The GET view: every curated key (present or not) + unknown file vars. */
|
||||
export function keyStatuses(): AuthKeyStatus[] {
|
||||
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS);
|
||||
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, new Set([...AUTH_PROVIDER_ENV_VARS, CUSTOM_PROVIDER.envVar]));
|
||||
}
|
||||
|
||||
/** The live value for one var, env-first (the env-wins rule) — the canonical
|
||||
@@ -187,6 +182,7 @@ export function keyStatuses(): AuthKeyStatus[] {
|
||||
* STT route was the one cold-boot hole: a first-ever request to it would
|
||||
* otherwise read env before any fill-gap ran). */
|
||||
export function keyValue(id: string): string | undefined {
|
||||
if (id === CUSTOM_AUTH_BINDING) return undefined;
|
||||
// The fresh-user switch: ONLY the file store counts (belt-and-
|
||||
// suspenders — the boot deletion already emptied the env; this guards
|
||||
// any env key reintroduced after boot).
|
||||
@@ -201,6 +197,10 @@ export function keyValue(id: string): string | undefined {
|
||||
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||
* no key at all: nothing to gate, present by definition. */
|
||||
export function providerKeyPresent(provider: string): boolean {
|
||||
if (provider === CUSTOM_PROVIDER.id) return customProviderAvailable();
|
||||
// Explicit opt-in + installation only, never authentication readiness.
|
||||
// Consulted at admission, not inside already-running agent loops.
|
||||
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
|
||||
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||
return info ? keyValue(info.envVar) !== undefined : true;
|
||||
@@ -213,6 +213,7 @@ export function providerKeyPresent(provider: string): boolean {
|
||||
* reports shadowedByEnv so the page can explain it).
|
||||
*/
|
||||
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
|
||||
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" };
|
||||
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
|
||||
if (!shape.ok) return { ok: false, error: shape.error };
|
||||
const trimmed = value.trim();
|
||||
@@ -232,6 +233,7 @@ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyS
|
||||
* wins) and the status shows the key still present from env.
|
||||
*/
|
||||
export function deleteKey(id: string): AuthKeyStatus {
|
||||
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) throw new Error("Use the custom endpoint editor");
|
||||
authStore.deleteAuthVar(id);
|
||||
const record = boot();
|
||||
if (record.fillGap.has(id) && !record.realEnv.has(id)) {
|
||||
@@ -244,3 +246,4 @@ export function deleteKey(id: string): AuthKeyStatus {
|
||||
// Reflect the file into the env at first import (idempotent; the module is
|
||||
// imported by every route/handler path that touches keys).
|
||||
initAuthEnv();
|
||||
installCustomProviderSource();
|
||||
|
||||
+37
-12
@@ -20,6 +20,7 @@
|
||||
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { customProviderAvailable } from "./custom-provider.ts";
|
||||
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
|
||||
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts";
|
||||
@@ -31,6 +32,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
|
||||
]);
|
||||
}
|
||||
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
||||
if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined;
|
||||
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
|
||||
}
|
||||
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
|
||||
@@ -47,8 +49,17 @@ import type {
|
||||
SlotPins,
|
||||
} from "../core/contracts/models.ts";
|
||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
|
||||
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
|
||||
import { AUTH_PROVIDER_KEYS, CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { claudeCodeAvailable } from "./claude-code-config.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
import {
|
||||
BACKGROUND_MODEL_ROLES,
|
||||
MODEL_SLOTS,
|
||||
customProviderUsable,
|
||||
type BackgroundModelRole,
|
||||
type ModelSlotName,
|
||||
type ModelsSettings,
|
||||
} from "../core/settings.ts";
|
||||
|
||||
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||
export type ModelDto = ModelCatalogEntry;
|
||||
@@ -135,13 +146,20 @@ export function catalogSlots(): SlotPins {
|
||||
* with keyPresent read env-wins through the keys seam, fresh per call —
|
||||
* saving a key immediately federates the provider into the picker. */
|
||||
export function catalogProviders(): CatalogProvider[] {
|
||||
piModels();
|
||||
return AUTH_PROVIDER_KEYS.map((p) => ({
|
||||
id: p.id,
|
||||
envVar: p.envVar,
|
||||
keyPresent: keyValue(p.envVar) !== undefined,
|
||||
})).concat([
|
||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
|
||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||
// The custom endpoint: a row only when usable (base URL set). keyPresent
|
||||
// reflects usability, not key presence (keyless local servers are allowed).
|
||||
...(customProviderUsable(effectiveSettings().customProvider)
|
||||
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: customProviderAvailable() }]
|
||||
: []),
|
||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
@@ -161,21 +179,22 @@ export function toModelDto(model: Model<Api>): ModelDto {
|
||||
reasoning: model.reasoning,
|
||||
input: [...model.input],
|
||||
supportedLevels: getSupportedThinkingLevels(model),
|
||||
cost: {
|
||||
cost: model.provider === CLAUDE_CODE_PROVIDER || model.provider === CUSTOM_PROVIDER.id ? null : {
|
||||
input: model.cost.input,
|
||||
output: model.cost.output,
|
||||
cacheRead: model.cost.cacheRead,
|
||||
cacheWrite: model.cost.cacheWrite,
|
||||
},
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow,
|
||||
maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** The live-limits index (context-meter amendment, owner 2026-09-23):
|
||||
* provider:id → contextWindow from pi's catalog, over the SAME keyed
|
||||
* providers the picker federates. The one source for every context
|
||||
* meter's denominator — the stats-bar join holds /api/models' copy,
|
||||
* providers the picker federates. Native CLI aliases are excluded:
|
||||
* only a session-specific CLI report can provide their denominator.
|
||||
* For other models the stats-bar join holds /api/models' copy,
|
||||
* and the activity feed (observability.ts) resolves job limits
|
||||
* server-side through this. No hardcoded fallback exists anywhere:
|
||||
* a model missing here reads limit-null (usage-only, honest render). */
|
||||
@@ -183,7 +202,7 @@ export function catalogContextLimits(): Map<string, number> {
|
||||
const keyed = keyedProviderIds();
|
||||
const index = new Map<string, number>();
|
||||
for (const provider of piModels().getProviders()) {
|
||||
if (!keyed.has(provider.id)) continue;
|
||||
if (!keyed.has(provider.id) || provider.id === CLAUDE_CODE_PROVIDER) continue;
|
||||
for (const model of piModels().getModels(provider.id)) {
|
||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||
}
|
||||
@@ -245,10 +264,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateModelsSettings(settings: ModelsSettings): SettingsValidation {
|
||||
/** Validate introduced/replaced model selections, not unchanged saved pins.
|
||||
* A disconnected subscription loses its available catalog, but its stored
|
||||
* selections must not prevent repairing another slot or editing thinking.
|
||||
* Only the SAME slot's provider+id can pass through; runtime admission stays
|
||||
* separate. Callers without a stored section retain strict catalog checking. */
|
||||
export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation {
|
||||
for (const name of MODEL_SLOTS) {
|
||||
const slot = settings[name];
|
||||
if (!slot) continue;
|
||||
const previous = stored?.[name]?.model;
|
||||
if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue;
|
||||
const verdict = validateSlotSpec(name, slot);
|
||||
if (!verdict.ok) return verdict;
|
||||
}
|
||||
@@ -280,9 +306,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
|
||||
// runtime — and the clamped value is what lands in the record. The
|
||||
// server is the truth: the record shows the clamped level after write.
|
||||
|
||||
/** Clamp one level against one model (identity when the model is unknown
|
||||
* or the level already supported — the existence gate rejects unknown
|
||||
* models separately, so this only ever sees real ones in practice). */
|
||||
/** Clamp one level against one model. Preserve the preference when metadata
|
||||
* is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
|
||||
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
||||
const model = connectionModel(provider, id);
|
||||
return model ? clampThinkingLevel(model, level) : level;
|
||||
|
||||
+28
-14
@@ -1,6 +1,4 @@
|
||||
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { SteerMessage } from "../../core/contracts.ts";
|
||||
import { noteSteerVisible } from "../jobs.ts";
|
||||
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
||||
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
|
||||
|
||||
@@ -79,6 +77,10 @@ export interface PiTurnSpec {
|
||||
* event stream itself is verbatim — provider errors ride their
|
||||
* message's stopReason/errorMessage through untouched). */
|
||||
onError?: (error: unknown) => string;
|
||||
/** Report settlement failure while the views are still open. */
|
||||
onSettleError?: (error: unknown) => string;
|
||||
/** Release liveness even if the normal finish seam throws. */
|
||||
onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
|
||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||
* Receives the agent's FULL final state (native messages) — the caller
|
||||
* persists the slice past its own base count. */
|
||||
@@ -119,11 +121,12 @@ export interface PiTurnHandle {
|
||||
}
|
||||
|
||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||
const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||
|
||||
let settled = false;
|
||||
let sawAbort = false;
|
||||
let reportedErrorText: string | undefined;
|
||||
|
||||
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
||||
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
|
||||
@@ -221,11 +224,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
// messages of its own (the prompt is composed per turn), so dropping
|
||||
// the role wholesale is exact; revisit if prompt-updates ever land.
|
||||
const settleWith = () => {
|
||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
||||
try {
|
||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
||||
} catch (err) {
|
||||
console.error("[turn] settlement failed:", err);
|
||||
try { onSettleFailure?.(err, reportedErrorText); }
|
||||
catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
|
||||
if (reportedErrorText === undefined) {
|
||||
reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
|
||||
send({ type: "error", errorText: reportedErrorText });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle = (event: AgentEvent) => {
|
||||
if (settled) return;
|
||||
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
||||
// every consumer downstream shares. No uniquifier → verbatim.
|
||||
const uniqued = uniquifier ? uniquifier.event(event) : event;
|
||||
@@ -239,15 +253,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
if (event.type === "message_end") {
|
||||
const m = event.message as { role?: string; stopReason?: string } | undefined;
|
||||
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
|
||||
// Dock fix (owner ruling 2026-09-26): an injected steer marker's
|
||||
// message_end = the card is visible to the learner — flip the
|
||||
// job's bookkeeping now (the turn may keep teaching for minutes;
|
||||
// settleSteers re-runs the flip as a no-op idempotently).
|
||||
if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
|
||||
}
|
||||
if (event.type === "agent_end") {
|
||||
closeAll();
|
||||
// Persist/report before the terminal closes every view to new frames.
|
||||
settleWith();
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
requestCycle?.bindRelease(handle);
|
||||
@@ -273,11 +283,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
// stream as stopReason "error"): surface, close, persist. If the
|
||||
// agent already settled (agent_end reached before the throw),
|
||||
// the seam has run — never settle twice.
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
console.error("[turn] error after settlement:", err);
|
||||
return;
|
||||
}
|
||||
console.error("[turn] agent loop failed:", err);
|
||||
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
||||
closeAll();
|
||||
reportedErrorText = onError?.(err) ?? "An error occurred.";
|
||||
send({ type: "error", errorText: reportedErrorText });
|
||||
settleWith();
|
||||
closeAll();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
*/
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
|
||||
function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
if (colon < 0 || isSubscriptionModel(id)) return undefined;
|
||||
// Native CLI routing determines billing. Adapter zero-rate placeholders
|
||||
// must never become an apparent $0 estimate or subscription classification.
|
||||
if (colon < 0 || isSubscriptionModel(id) || [CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER.id].includes(id.slice(0, colon))) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ import type {
|
||||
AppearanceSettings,
|
||||
BackgroundModelRole,
|
||||
CompactionSettings,
|
||||
CustomModelOverride,
|
||||
GeneralSettings,
|
||||
DictationSettings,
|
||||
GraphSettings,
|
||||
ModelsSettings,
|
||||
RoleModelOverride,
|
||||
} from "../core/settings.ts";
|
||||
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, CUSTOM_MODEL_PIN_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
||||
import { isDictationLanguage } from "../core/dictation.ts";
|
||||
|
||||
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
||||
export type SettingsBodyResult<T> =
|
||||
@@ -105,6 +108,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
|
||||
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
if (raw.enabled === undefined) return { ok: true, settings: {} };
|
||||
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
||||
return { ok: true, settings: { enabled: raw.enabled } };
|
||||
}
|
||||
|
||||
/** agent.models: only the six known background roles; each override's
|
||||
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
||||
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
||||
@@ -401,8 +413,56 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<C
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
/** The section→parser map for PUT /api/settings/[section] (the route also
|
||||
* carries the special prompts sub-resource). */
|
||||
/** dictation: { language?: one of Nova-3's supported language codes } */
|
||||
export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<DictationSettings> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
if (raw.language === undefined) return { ok: true, settings: {} };
|
||||
if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" };
|
||||
return { ok: true, settings: { language: raw.language } };
|
||||
}
|
||||
|
||||
/** The custom endpoint's per-model pins (PATCH /api/custom-provider): the
|
||||
* whole desired map, replace-whole like every section. Strict write side — a
|
||||
* token count must be a whole number at or above the floor (the read side
|
||||
* clamps, this rejects — the compaction discipline). `reasoning` is the
|
||||
* tri-state capability pin, not a backend execution toggle. */
|
||||
export function parseCustomModelOverridesBody(
|
||||
body: unknown,
|
||||
): SettingsBodyResult<Record<string, CustomModelOverride>> {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||
const map = asRecord(raw.overrides);
|
||||
if (!map) return { ok: false, error: "overrides must be a { modelId: { context?, maxTokens?, reasoning? } } map" };
|
||||
const out: Record<string, CustomModelOverride> = {};
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
const id = key.trim();
|
||||
if (!id) return { ok: false, error: "override keys must be non-empty model ids" };
|
||||
const entry = asRecord(value);
|
||||
if (!entry) return { ok: false, error: `override for ${id} must be an object` };
|
||||
const pin: CustomModelOverride = {};
|
||||
for (const field of ["context", "maxTokens"] as const) {
|
||||
const v = entry[field];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < CUSTOM_MODEL_PIN_FLOOR) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${field} for ${id} must be a whole number of tokens at least ${CUSTOM_MODEL_PIN_FLOOR}`,
|
||||
};
|
||||
}
|
||||
pin[field] = v;
|
||||
}
|
||||
if (entry.reasoning !== undefined) {
|
||||
if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
|
||||
pin.reasoning = entry.reasoning;
|
||||
}
|
||||
Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
return { ok: true, settings: out };
|
||||
}
|
||||
|
||||
/** The section→parser map for PUT /api/settings/[section]. The custom endpoint
|
||||
* is NOT a section — it connects via POST /api/custom-provider. */
|
||||
export const SETTINGS_SECTIONS = {
|
||||
general: parseGeneralSettingsBody,
|
||||
chat: parseSettingsBody,
|
||||
@@ -411,6 +471,8 @@ export const SETTINGS_SECTIONS = {
|
||||
graph: parseGraphSettingsBody,
|
||||
compaction: parseCompactionSettingsBody,
|
||||
models: parseModelsSettingsBody,
|
||||
dictation: parseDictationSettingsBody,
|
||||
claudeCode: parseClaudeCodeSettingsBody,
|
||||
} as const;
|
||||
|
||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
|
||||
import { jobStore } from "../store/jobs.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
|
||||
import { syncCustomProvider } from "./custom-provider.ts";
|
||||
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
|
||||
export const stores = {
|
||||
chatStore,
|
||||
@@ -32,3 +33,7 @@ export type Stores = typeof stores;
|
||||
export function effectiveSettings(): EffectiveSettings {
|
||||
return resolveSettings(settingsStore.readSettings());
|
||||
}
|
||||
|
||||
// Boot: register the custom endpoint (when configured) into piModels(); the
|
||||
// POST /api/custom-provider route re-syncs after every connect.
|
||||
syncCustomProvider();
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
import { emit } from "../core/events.ts";
|
||||
import { clearTurnFailure } from "./turn-runs.ts";
|
||||
|
||||
interface LhGlobal {
|
||||
__lhTurnRegistry?: Map<string, number>;
|
||||
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
|
||||
*/
|
||||
export function beginTurn(chatId: string): boolean {
|
||||
if (isTurnRunning(chatId)) return false;
|
||||
clearTurnFailure(chatId);
|
||||
inFlight.set(chatId, Date.now());
|
||||
emit("turn-started", { chatId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
|
||||
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
|
||||
* durable truth). Emits only when an entry was actually present: a
|
||||
* Mark the chat's turn as finished after normal settlement, or after the
|
||||
* failure notice has been retained by settlement cleanup. A subsequent
|
||||
* fetch reads durable truth, never a promise that an unsaved reply landed.
|
||||
* Emits only when an entry was actually present: a
|
||||
* defensive no-op endTurn must not fire a spurious settle.
|
||||
*/
|
||||
export function endTurn(chatId: string): void {
|
||||
|
||||
+30
-5
@@ -13,7 +13,9 @@
|
||||
* Registration lifecycle: chat-handler registers the handle the moment
|
||||
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
||||
* — after persist, usage, extraction, and endTurn — so anything that sees
|
||||
* "no running turn" also sees an already-persisted record. The per-chat
|
||||
* "no running turn" sees durable record truth. Failed settlement also
|
||||
* retains its small notice before unregistering, so late attachments do
|
||||
* not mistake an unsaved reply for success. The per-chat
|
||||
* 409 guard means a chatId can hold at most one handle, and beginTurn's
|
||||
* 30-minute TTL bounds the pathological never-settling case the same way
|
||||
* it does for the liveness map: if onFinish never fires, the stale entry
|
||||
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
|
||||
|
||||
interface LhTurnRunsGlobal {
|
||||
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
||||
__lhTurnFailures?: Map<string, string>;
|
||||
}
|
||||
|
||||
function runs(): Map<string, PiTurnHandle> {
|
||||
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
|
||||
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
|
||||
}
|
||||
|
||||
/** One small, transient notice per failed session. Never stores messages.
|
||||
* Cleared on the next accepted begin, session deletion, or process exit. */
|
||||
function failures(): Map<string, string> {
|
||||
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
|
||||
return (g.__lhTurnFailures ??= new Map<string, string>());
|
||||
}
|
||||
|
||||
export function rememberTurnFailure(chatId: string, errorText: string): void {
|
||||
failures().set(chatId, errorText);
|
||||
}
|
||||
|
||||
export function turnFailure(chatId: string): string | undefined {
|
||||
return failures().get(chatId);
|
||||
}
|
||||
|
||||
export function clearTurnFailure(chatId: string): void {
|
||||
failures().delete(chatId);
|
||||
}
|
||||
|
||||
/** Register the chat's running turn (chat-handler, right after the turn
|
||||
* starts). One per chat by construction — the 409 guard. */
|
||||
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
||||
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
|
||||
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
||||
* turn (raced settle, or the client checked turnInFlight a beat too
|
||||
* late): a same-dialect SSE response whose single chunk tells the client
|
||||
* "nothing to attach to — the record is current, refetch it". Chosen as
|
||||
* an EVENT (not a status code) so the reattach path consumes ONE wire
|
||||
* "nothing to attach to — refetch durable truth". A retained settlement
|
||||
* failure is delivered first, including when no view attached in time.
|
||||
* Chosen as an EVENT (not a status code), consuming ONE wire
|
||||
* protocol end to end. */
|
||||
export function settledTurnResponse(): Response {
|
||||
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
|
||||
export function settledTurnResponse(chatId?: string): Response {
|
||||
const errorText = chatId ? turnFailure(chatId) : undefined;
|
||||
const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
|
||||
return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The chat-turn services — one named, error-contained seam per turn
|
||||
* The chat-turn services — one named seam per turn
|
||||
* responsibility, extracted from the chat-handler so it composes instead of
|
||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||
* job runner) — no module here imports an adapter.
|
||||
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
||||
|
||||
/** Distinguish a failed canonical save from later bookkeeping failures. */
|
||||
export class TurnPersistenceError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("The reply could not be saved.", { cause });
|
||||
this.name = "TurnPersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
||||
* turn's own messages — the agent loop's native history, provider/model/
|
||||
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
|
||||
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||
* idempotent (persist replays are safe).
|
||||
*
|
||||
* Returns the stamped new messages (usage provenance reads them).
|
||||
* Returns the stamped new messages (usage provenance reads them). Throws
|
||||
* TurnPersistenceError on failure so the runner can report it before DONE.
|
||||
*/
|
||||
export function persistTurn(
|
||||
chatStore: ChatStore,
|
||||
@@ -56,7 +65,7 @@ export function persistTurn(
|
||||
): LhMessage[] {
|
||||
try {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return [];
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
const stamped: LhMessage[] = [];
|
||||
for (const m of turnMessages.slice(baseCount)) {
|
||||
const message = m as LhMessage;
|
||||
@@ -79,7 +88,7 @@ export function persistTurn(
|
||||
return stamped;
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
return [];
|
||||
throw new TurnPersistenceError(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
|
||||
import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
|
||||
|
||||
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
|
||||
/** Same-origin browser controls, not user authentication. Web/dev deliberately
|
||||
* retains the existing shared-server/API-key trust model. Never trust forwarded
|
||||
* origins or add CORS grants here. A public deployment needs its own access gate. */
|
||||
export function subscriptionRequestOrigin(request: Request): string | undefined {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const host = request.headers.get("host") ?? url.host;
|
||||
const addressed = new URL(`${url.protocol}//${host}`);
|
||||
if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
|
||||
return addressed.origin;
|
||||
} catch { return; }
|
||||
}
|
||||
function authorized(request: Request): boolean {
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
||||
const supplied = request.headers.get("origin");
|
||||
return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
|
||||
}
|
||||
function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
|
||||
export async function subscriptionControlsResponse(request: Request): Promise<Response> {
|
||||
if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
|
||||
if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
|
||||
try {
|
||||
if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
|
||||
if (request.method !== "POST") return error("Unsupported method.", 405);
|
||||
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
|
||||
// Bound both advertised and streamed input before parsing commands.
|
||||
if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return error("Expected a command.", 400);
|
||||
const chunks: Uint8Array[] = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read(); if (next.done) break;
|
||||
size += next.value.byteLength;
|
||||
if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
|
||||
const fields = command as Record<string, unknown>;
|
||||
const providerId = fields.providerId;
|
||||
if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
|
||||
const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
|
||||
if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
|
||||
return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
|
||||
} catch {
|
||||
return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
|
||||
}
|
||||
}
|
||||
/** One-use same-origin navigation. Full provider authorization URLs go only to
|
||||
* a top-level browser redirect, never status JSON, the SSE bus or application JS. */
|
||||
export async function subscriptionBrowserResponse(request: Request): Promise<Response> {
|
||||
if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
|
||||
const id = new URL(request.url).searchParams.get("id");
|
||||
if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
|
||||
const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
|
||||
if (!target) return error("This sign-in action is no longer available.", 410);
|
||||
return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
|
||||
* its existing main-process owner; a broken desktop IPC channel cannot fall
|
||||
* back to these files. One server process must own each configured auth dir. */
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { AUTH_FILE } from "../../config.ts";
|
||||
import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
|
||||
import { createSubscriptions } from "../../electron/subscriptions.cjs";
|
||||
import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
|
||||
import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
|
||||
import { emit } from "../core/events.ts";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
|
||||
import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
|
||||
export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
|
||||
interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise<boolean> }
|
||||
const KEY = Symbol.for("lh.webSubscriptions");
|
||||
interface Registry {
|
||||
owner?: ReturnType<typeof createWebSubscriptionOwner>;
|
||||
}
|
||||
function registry(): Registry {
|
||||
const global = globalThis as typeof globalThis & { [KEY]?: Registry };
|
||||
return global[KEY] ??= {};
|
||||
}
|
||||
function publish(channel: Channel, packet: Packet) {
|
||||
publishWebSubscriptionPacket(channel, packet);
|
||||
emit("subscriptions-changed", {});
|
||||
}
|
||||
async function loadSDK() {
|
||||
const [sdk, copilot, xai] = await Promise.all([
|
||||
import("@earendil-works/pi-ai"),
|
||||
import("@earendil-works/pi-ai/providers/github-copilot"),
|
||||
import("@earendil-works/pi-ai/providers/xai"),
|
||||
]);
|
||||
return { ...sdk, ...copilot, ...xai };
|
||||
}
|
||||
|
||||
/** Injectable factories/stores keep all test authentication offline. */
|
||||
export function createWebSubscriptionOwner(options: {
|
||||
directory: string;
|
||||
chatgptStore?: Parameters<typeof createChatGPT>[0]["store"];
|
||||
sdk?: unknown;
|
||||
onPacket?: (channel: Channel, packet: Packet) => void;
|
||||
} ) {
|
||||
const notify = options.onPacket ?? (() => {});
|
||||
const launches = new AsyncLocalStorage<string[]>();
|
||||
const actions = new Map<string, BrowserAction>();
|
||||
let chatGPTLaunch = 0;
|
||||
const openExternal = async (target: string) => {
|
||||
const captured = launches.getStore();
|
||||
if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
|
||||
captured.push(target);
|
||||
};
|
||||
const chatgpt = createChatGPT({
|
||||
store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
|
||||
openExternal,
|
||||
onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
|
||||
});
|
||||
const native = createSubscriptions({
|
||||
directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
|
||||
onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
|
||||
});
|
||||
return {
|
||||
chatgpt, native,
|
||||
async control(providerId: string, command: Record<string, unknown>, origin: string) {
|
||||
const targets: string[] = [];
|
||||
const value = await launches.run(targets, () => providerId === "chatgpt"
|
||||
? chatGPTControl(command, chatgpt)
|
||||
: subscriptionControl({ ...command, providerId }, native));
|
||||
let browserAction: string | undefined;
|
||||
const target = targets[0];
|
||||
if (target) {
|
||||
const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
|
||||
for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
|
||||
const id = randomUUID();
|
||||
const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
|
||||
const current = providerId === "chatgpt"
|
||||
? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
|
||||
: async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
|
||||
actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
|
||||
browserAction = `/api/subscriptions/browser?id=${id}`;
|
||||
}
|
||||
return { value, ...(browserAction ? { browserAction } : {}) };
|
||||
},
|
||||
async takeBrowserAction(id: string, origin: string): Promise<string | undefined> {
|
||||
const action = actions.get(id);
|
||||
if (!action || action.origin !== origin) return;
|
||||
actions.delete(id);
|
||||
if (action.expires <= Date.now() || !await action.current()) return;
|
||||
return action.target;
|
||||
},
|
||||
close() { actions.clear(); chatgpt.close(); native.close(); },
|
||||
};
|
||||
}
|
||||
export function webSubscriptionOwner() {
|
||||
if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
|
||||
const state = registry();
|
||||
if (!state.owner) {
|
||||
state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
|
||||
process.once("exit", () => state.owner?.close());
|
||||
}
|
||||
return state.owner;
|
||||
}
|
||||
/** Private runtime calls only: no HTTP lease/token operation exists. */
|
||||
export async function webSubscriptionRequest<T>(channel: Channel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||
const owner = webSubscriptionOwner();
|
||||
let value: unknown;
|
||||
if (channel === "lh:chatgpt") {
|
||||
if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
|
||||
else if (op === "models") value = await owner.chatgpt.models();
|
||||
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
|
||||
else throw new Error("Invalid private operation.");
|
||||
} else {
|
||||
if (op === "snapshot") value = await owner.native.snapshot();
|
||||
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
|
||||
else throw new Error("Invalid private operation.");
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
configureWebSubscriptionRuntime(webSubscriptionRequest);
|
||||
|
||||
export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
|
||||
const owner = webSubscriptionOwner();
|
||||
const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
|
||||
return { chatgpt, native };
|
||||
}
|
||||
+43
-4
@@ -7,9 +7,10 @@
|
||||
*
|
||||
* The server (src/server/keys.ts) is the only intended writer: it applies
|
||||
* shape validation before setAuthVar and env precedence on top of
|
||||
* authVars(). This adapter persists what it is given, never throws
|
||||
* (absent/corrupt file = {} = "no keys yet", the documented first-boot
|
||||
* state, matching settings doctrine), and preserves unknown vars verbatim
|
||||
* authVars(). Ordinary reads never throw (absent/corrupt file = {} =
|
||||
* "no keys yet"); custom-only pair reads/writes below are deliberately
|
||||
* strict so an unreadable private record is never overwritten as {}.
|
||||
* Ordinary writes preserve unknown string vars verbatim
|
||||
* (forward-compat: future entries — e.g. a plans-login token — ride along
|
||||
* untouched until a catalog entry adopts them).
|
||||
*/
|
||||
@@ -18,6 +19,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AUTH_FILE } from "../../config.ts";
|
||||
import type { AuthStore } from "../core/ports.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { CUSTOM_AUTH_BINDING, parseCustomAuthBinding, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
|
||||
/** The flat string→string map, or {} for absent/corrupt/non-object. */
|
||||
function readMap(): Record<string, string> {
|
||||
@@ -36,7 +39,7 @@ function readMap(): Record<string, string> {
|
||||
* leave a 0644 file (secrets) or a truncated JSON (looks like "no keys").
|
||||
* tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
|
||||
* rename so the mode never exists relaxed on the final path. */
|
||||
function writeMap(map: Record<string, string>): void {
|
||||
function writeMap(map: Record<string, unknown>): void {
|
||||
const dir = path.dirname(AUTH_FILE);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
|
||||
@@ -50,14 +53,50 @@ export const authStore: AuthStore = {
|
||||
return readMap();
|
||||
},
|
||||
setAuthVar(name, value) {
|
||||
assertOrdinaryVar(name);
|
||||
const map = readMap();
|
||||
map[name] = value;
|
||||
writeMap(map);
|
||||
},
|
||||
deleteAuthVar(name) {
|
||||
assertOrdinaryVar(name);
|
||||
const map = readMap();
|
||||
if (!(name in map)) return; // absent = silent success, idempotent
|
||||
delete map[name];
|
||||
writeMap(map);
|
||||
},
|
||||
};
|
||||
|
||||
/** Custom writes must never turn an unreadable/corrupt private file into {}.
|
||||
* Other providers retain their existing adapter semantics. Unknown JSON values
|
||||
* survive custom-only updates, not just the ordinary string projection. */
|
||||
function readCustomMap(): Record<string, unknown> {
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(AUTH_FILE, "utf8"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Custom authorization storage unavailable"); }
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(text); } catch { throw new Error("Custom authorization storage unavailable"); }
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Custom authorization storage unavailable");
|
||||
const map = raw as Record<string, unknown>;
|
||||
if (Object.hasOwn(map, CUSTOM_AUTH_BINDING) && !parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING])) throw new Error("Invalid custom authorization record");
|
||||
if (Object.hasOwn(map, CUSTOM_PROVIDER.envVar) && typeof map[CUSTOM_PROVIDER.envVar] !== "string") throw new Error("Invalid custom credential record");
|
||||
return map;
|
||||
}
|
||||
function assertOrdinaryVar(name: string): void {
|
||||
if (name === CUSTOM_AUTH_BINDING || name === CUSTOM_PROVIDER.envVar) throw new Error("Use the custom endpoint editor");
|
||||
}
|
||||
/** One immutable file snapshot. Keyless callers never invoke fileKey(). */
|
||||
export function readCustomAuth() {
|
||||
const map = readCustomMap();
|
||||
return { binding: parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING]), fileKey: () => map[CUSTOM_PROVIDER.envVar] as string | undefined };
|
||||
}
|
||||
/** Replace just the custom pair in ONE existing atomic record write. */
|
||||
export function writeCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
|
||||
const map = readCustomMap();
|
||||
if (binding && !parseCustomAuthBinding(JSON.stringify(binding))) throw new Error("Invalid custom authorization record");
|
||||
if (fileKey === undefined) delete map[CUSTOM_PROVIDER.envVar];
|
||||
else Object.defineProperty(map, CUSTOM_PROVIDER.envVar, { value: fileKey, enumerable: true, configurable: true, writable: true });
|
||||
if (binding === undefined) delete map[CUSTOM_AUTH_BINDING];
|
||||
else Object.defineProperty(map, CUSTOM_AUTH_BINDING, { value: JSON.stringify(binding), enumerable: true, configurable: true, writable: true });
|
||||
writeMap(map);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function createJob(init: CreateJobInit): JobRecord {
|
||||
kind: init.kind,
|
||||
chatId: init.chatId,
|
||||
toolCallId: init.toolCallId,
|
||||
...(init.input !== undefined ? { input: init.input } : {}),
|
||||
status: "running",
|
||||
activity: init.activity ?? "starting",
|
||||
createdAt: now,
|
||||
|
||||
Reference in New Issue
Block a user