Merge current main into PR61 finishing branch
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,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";
|
||||
|
||||
@@ -124,16 +124,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");
|
||||
}
|
||||
@@ -1113,6 +1117,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 };
|
||||
@@ -1148,14 +1153,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 () => {
|
||||
@@ -1188,7 +1193,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
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { 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";
|
||||
@@ -166,7 +167,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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts";
|
||||
import {
|
||||
accumulateJobUsage,
|
||||
buildSubagentSteer,
|
||||
isSubagentFailure,
|
||||
piUsageToStep,
|
||||
type DemoResult,
|
||||
type DemoToolInput,
|
||||
@@ -40,13 +41,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.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -67,9 +68,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):
|
||||
@@ -96,8 +97,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>;
|
||||
}
|
||||
|
||||
@@ -270,14 +271,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
|
||||
@@ -295,27 +297,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. */
|
||||
@@ -343,7 +346,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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+26
-12
@@ -15,10 +15,11 @@ 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 { 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";
|
||||
|
||||
@@ -145,22 +146,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;
|
||||
}
|
||||
@@ -174,7 +180,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 systemPrompt = opts.promptSwitches
|
||||
? composeInstructions(kind, opts.promptSwitches)
|
||||
: composedInstructions[kind]!;
|
||||
@@ -190,7 +196,16 @@ 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,
|
||||
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
|
||||
? [withArgumentHeal(sendToUserTool)]
|
||||
: []),
|
||||
],
|
||||
messages: opts.messages ?? [],
|
||||
},
|
||||
streamFn: engine.streamFn,
|
||||
@@ -312,10 +327,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+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
|
||||
|
||||
+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,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[];
|
||||
|
||||
@@ -47,6 +47,7 @@ export * from "./input-events.ts";
|
||||
// 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";
|
||||
@@ -58,6 +59,7 @@ export * from "./fuzzy.ts";
|
||||
// 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
|
||||
|
||||
+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)));
|
||||
}
|
||||
|
||||
+91
-20
@@ -98,6 +98,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 +551,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 +563,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 +606,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 }],
|
||||
@@ -570,11 +620,12 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
// Strip the record id: the wire carries the native shape only.
|
||||
const { id: _id, ...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));
|
||||
@@ -618,15 +669,35 @@ export function danglingQuestionOf(
|
||||
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
|
||||
|
||||
+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") {
|
||||
|
||||
@@ -136,6 +136,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
|
||||
|
||||
@@ -22,22 +22,15 @@
|
||||
|
||||
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
|
||||
@@ -48,6 +41,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 {
|
||||
@@ -66,7 +60,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. */
|
||||
@@ -172,16 +166,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(`### 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(`### 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);
|
||||
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type BackgroundModelRole,
|
||||
type FactorySettings,
|
||||
type GeneralSettings,
|
||||
type DictationSettings,
|
||||
type CompactionSettings,
|
||||
type GraphSettings,
|
||||
type ModelSlot,
|
||||
@@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
|
||||
/** The concrete view: every knob resolved, no absent fields. */
|
||||
export interface EffectiveSettings {
|
||||
general: Required<GeneralSettings>;
|
||||
dictation: Required<DictationSettings>;
|
||||
agent: {
|
||||
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
|
||||
/** Delta map as stored — consumers resolve absent key → enabled. */
|
||||
@@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
||||
general: {
|
||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||
},
|
||||
dictation: {
|
||||
language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
|
||||
},
|
||||
agent: {
|
||||
models,
|
||||
tools: agent.tools ?? {},
|
||||
|
||||
+17
-1
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.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. */
|
||||
@@ -231,10 +232,15 @@ 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;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
@@ -361,6 +367,7 @@ export interface CompactionSettings {
|
||||
export interface UserSettings {
|
||||
version: number;
|
||||
general?: GeneralSettings;
|
||||
dictation?: DictationSettings;
|
||||
agent?: AgentSettings;
|
||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||
models?: ModelsSettings;
|
||||
@@ -378,6 +385,7 @@ export interface UserSettings {
|
||||
export interface FactorySettings {
|
||||
version: number;
|
||||
general: Required<GeneralSettings>;
|
||||
dictation: Required<DictationSettings>;
|
||||
appearance: {
|
||||
mode: "dark" | "light";
|
||||
background: string | null;
|
||||
@@ -397,6 +405,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
|
||||
@@ -615,6 +624,13 @@ 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.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
+27
-1
@@ -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";
|
||||
@@ -124,6 +124,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];
|
||||
@@ -218,6 +226,24 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
||||
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 (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.
|
||||
|
||||
+84
-22
@@ -17,7 +17,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import {
|
||||
deliverJobResult,
|
||||
noteSteerVisible,
|
||||
noteSteersDurable,
|
||||
reconcileUserSteers,
|
||||
registerLiveAgent,
|
||||
restoreUncommittedAutomatic,
|
||||
@@ -42,8 +42,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";
|
||||
@@ -55,10 +55,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
|
||||
@@ -79,6 +80,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
||||
* for write-ahead persistence, turn liveness, and the turn services.
|
||||
*/
|
||||
|
||||
function settlementErrorText(error: unknown): string {
|
||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||
}
|
||||
|
||||
const jobRunner = createJobRunner({
|
||||
chatStore: stores.chatStore,
|
||||
jobStore: stores.jobStore,
|
||||
@@ -182,6 +187,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
|
||||
@@ -255,7 +271,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
|
||||
@@ -289,7 +318,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
|
||||
}
|
||||
@@ -309,18 +342,26 @@ 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 {
|
||||
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
|
||||
@@ -346,7 +387,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.
|
||||
@@ -365,19 +406,23 @@ 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 = {
|
||||
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
|
||||
@@ -468,11 +513,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,
|
||||
@@ -496,10 +542,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.
|
||||
@@ -545,7 +607,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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -43,4 +43,5 @@ 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";
|
||||
|
||||
+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" });
|
||||
|
||||
@@ -248,10 +248,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;
|
||||
}
|
||||
@@ -283,9 +290,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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -19,12 +19,14 @@ import type {
|
||||
BackgroundModelRole,
|
||||
CompactionSettings,
|
||||
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 { 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> =
|
||||
@@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<C
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
/** 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 section→parser map for PUT /api/settings/[section] (the route also
|
||||
* carries the special prompts sub-resource). */
|
||||
export const SETTINGS_SECTIONS = {
|
||||
@@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = {
|
||||
graph: parseGraphSettingsBody,
|
||||
compaction: parseCompactionSettingsBody,
|
||||
models: parseModelsSettingsBody,
|
||||
dictation: parseDictationSettingsBody,
|
||||
} as const;
|
||||
|
||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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